Skip to content
SaaS4Builders
Customization

Adding a Feature

End-to-end walkthrough for adding a new domain feature to SaaS4Builders: backend layers, frontend modules, routing, i18n, and testing.

This walkthrough takes you through adding a complete domain feature — from database model to rendered page. You will create backend domain logic, application use cases, HTTP endpoints, frontend data layer, composables, components, and tests.

The example feature is Tasks — a simple tenant-scoped CRUD domain. The patterns shown here apply to any feature you build.


The Request Lifecycle

Every feature follows this flow:

HTTP Request
  → FormRequest (validate + toDto())
    → Action or Query (business logic)
      → Eloquent Model
        → Resource (JSON output)
          → Nuxt API module (Zod validation)
            → Composable (reactive state)
              → Vue Component

Understanding this flow is key. Each layer has a single job, and you build outward from the domain.


Where Does My Code Go?

Use this decision table when you are unsure where to place new code:

I want to...I create in...
Create, update, or delete an entityApplication/<Domain>/Actions/
List, filter, or paginateApplication/<Domain>/Queries/
Define input data for a use caseApplication/<Domain>/DTO/*Data.php
Define an interface for an external serviceDomain/<Domain>/Contracts/
Create an enum or value objectDomain/<Domain>/Enums/ or ValueObjects/
Implement an external integrationInfrastructure/<Domain>/Providers/<Provider>/
Transform an external responseInfrastructure/<Domain>/Mappers/
Validate HTTP inputHttp/Requests/Api/V1/<Context>/
Format JSON outputHttp/Resources/Api/V1/<Context>/
Add a reusable business ruleDomain/<Domain>/Rules/

Naming Conventions

ElementConventionExample
ActionBusiness verb, no suffixCreateTask, CompleteTask
QueryRead verb, no suffixListTasks, FindTaskById
Input DTO*Data or *FiltersCreateTaskData, ListTasksFilters
Transfer DTOProvider-agnosticExternalTask
ResourceSingular nounTaskResource
Frontend composableuse<Feature>()useTasks()
Frontend API moduleuse<Feature>Api()useTasksApi()

Backend: Domain Layer

Start with your domain — the business concepts that exist independently of frameworks or HTTP.

Create the domain directory:

backend/app/Domain/Tasks/
├── Enums/
│   └── TaskStatus.php
├── Contracts/           # Only if external integrations are needed
└── ValueObjects/        # Only if you have value objects

Enum

Enums represent the fixed set of states your domain supports:

backend/app/Domain/Tasks/Enums/TaskStatus.php
<?php

declare(strict_types=1);

namespace App\Domain\Tasks\Enums;

enum TaskStatus: string
{
    case Pending = 'pending';
    case InProgress = 'in_progress';
    case Completed = 'completed';
    case Cancelled = 'cancelled';

    public function isTerminal(): bool
    {
        return in_array($this, [self::Completed, self::Cancelled], true);
    }
}
Domain code has zero dependencies on Laravel, Eloquent, or the HTTP layer. Enums, value objects, and contracts are pure PHP. This makes them trivially testable and portable.

Contract (Optional)

If your feature needs to call an external service, define a contract in the Domain. The Infrastructure layer provides the implementation.

backend/app/Domain/Tasks/Contracts/TaskNotifierInterface.php
<?php

declare(strict_types=1);

namespace App\Domain\Tasks\Contracts;

use App\Models\Task;

interface TaskNotifierInterface
{
    public function notifyAssignee(Task $task): void;
}

For a pure CRUD feature with no external integrations, you can skip the Contracts/ directory entirely.

See Domain Layer for the full domain building blocks.


Backend: Application Layer

The Application layer contains your use cases. Actions mutate state. Queries read data.

backend/app/Application/Tasks/
├── Actions/
│   └── CreateTask.php
├── Queries/
│   └── ListTasks.php
└── DTO/
    ├── CreateTaskData.php
    └── ListTasksFilters.php

Input DTO

Input DTOs carry validated data from the HTTP layer without any HTTP dependency:

backend/app/Application/Tasks/DTO/CreateTaskData.php
<?php

declare(strict_types=1);

namespace App\Application\Tasks\DTO;

final readonly class CreateTaskData
{
    public function __construct(
        public string $title,
        public ?string $description = null,
    ) {}
}
backend/app/Application/Tasks/DTO/ListTasksFilters.php
<?php

declare(strict_types=1);

namespace App\Application\Tasks\DTO;

final readonly class ListTasksFilters
{
    public function __construct(
        public ?string $q = null,
        public ?string $status = null,
        public int $perPage = 15,
    ) {}
}

Action (Mutation)

Actions wrap mutations in a database transaction. One action = one business operation:

backend/app/Application/Tasks/Actions/CreateTask.php
<?php

declare(strict_types=1);

namespace App\Application\Tasks\Actions;

use App\Application\Tasks\DTO\CreateTaskData;
use App\Domain\Tasks\Enums\TaskStatus;
use App\Models\Task;
use Illuminate\Support\Facades\DB;

final class CreateTask
{
    public function execute(CreateTaskData $data, string $tenantId): Task
    {
        return DB::transaction(fn () => Task::create([
            'tenant_id' => $tenantId,
            'title' => $data->title,
            'description' => $data->description,
            'status' => TaskStatus::Pending,
        ]));
    }
}

Query (Read-Only)

Queries are read-only — no side effects:

backend/app/Application/Tasks/Queries/ListTasks.php
<?php

declare(strict_types=1);

namespace App\Application\Tasks\Queries;

use App\Application\Tasks\DTO\ListTasksFilters;
use App\Models\Task;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;

final class ListTasks
{
    public function execute(ListTasksFilters $filters, string $tenantId): LengthAwarePaginator
    {
        return Task::query()
            ->where('tenant_id', $tenantId)
            ->when($filters->status, fn ($query, $status) =>
                $query->where('status', $status)
            )
            ->when($filters->q, fn ($query, $q) =>
                $query->where('title', 'like', "%{$q}%")
            )
            ->latest()
            ->paginate($filters->perPage);
    }
}

See Application Layer for the full Action vs Query decision guide.


Backend: HTTP Layer

The HTTP layer validates input, delegates to use cases, and formats output. Controllers stay thin.

backend/app/Http/
├── Controllers/Api/V1/Tenant/
│   └── TaskController.php
├── Requests/Api/V1/Tenant/
│   ├── CreateTaskRequest.php
│   └── ListTasksRequest.php
└── Resources/Api/V1/Tenant/
    └── TaskResource.php

FormRequest with toDto()

Every FormRequest must expose a toDto() method that maps validated HTTP input to an Application DTO:

backend/app/Http/Requests/Api/V1/Tenant/CreateTaskRequest.php
<?php

declare(strict_types=1);

namespace App\Http\Requests\Api\V1\Tenant;

use App\Application\Tasks\DTO\CreateTaskData;
use Illuminate\Foundation\Http\FormRequest;

final class CreateTaskRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:255'],
            'description' => ['nullable', 'string', 'max:5000'],
        ];
    }

    public function toDto(): CreateTaskData
    {
        return new CreateTaskData(
            title: $this->validated('title'),
            description: $this->validated('description'),
        );
    }
}
backend/app/Http/Requests/Api/V1/Tenant/ListTasksRequest.php
<?php

declare(strict_types=1);

namespace App\Http\Requests\Api\V1\Tenant;

use App\Application\Tasks\DTO\ListTasksFilters;
use Illuminate\Foundation\Http\FormRequest;

final class ListTasksRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'q' => ['nullable', 'string', 'max:255'],
            'status' => ['nullable', 'string', 'in:pending,in_progress,completed,cancelled'],
            'per_page' => ['nullable', 'integer', 'min:1', 'max:200'],
        ];
    }

    public function toDto(): ListTasksFilters
    {
        return new ListTasksFilters(
            q: $this->validated('q'),
            status: $this->validated('status'),
            perPage: (int) ($this->validated('per_page') ?? 15),
        );
    }
}

Resource

Resources format your model into JSON. All keys must use snake_case:

backend/app/Http/Resources/Api/V1/Tenant/TaskResource.php
<?php

declare(strict_types=1);

namespace App\Http\Resources\Api\V1\Tenant;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

/**
 * @mixin \App\Models\Task
 */
final class TaskResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => (string) $this->id,
            'title' => $this->title,
            'description' => $this->description,
            'status' => $this->status->value,
            'created_at' => $this->created_at?->toIso8601String(),
            'updated_at' => $this->updated_at?->toIso8601String(),
        ];
    }
}
Resource keys must be snake_case. This is non-negotiable — it is the API contract. The frontend API client automatically transforms snake_case responses to camelCase for TypeScript consumption.

Controller

Controllers are thin — they validate, delegate, and return:

backend/app/Http/Controllers/Api/V1/Tenant/TaskController.php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api\V1\Tenant;

use App\Application\Tasks\Actions\CreateTask;
use App\Application\Tasks\Queries\ListTasks;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\Tenant\CreateTaskRequest;
use App\Http\Requests\Api\V1\Tenant\ListTasksRequest;
use App\Http\Resources\Api\V1\Tenant\TaskResource;
use Illuminate\Http\JsonResponse;

final class TaskController extends Controller
{
    public function index(ListTasksRequest $request, ListTasks $query): JsonResponse
    {
        $tasks = $query->execute(
            $request->toDto(),
            (string) $request->user()->tenant_id
        );

        return TaskResource::collection($tasks)->response();
    }

    public function store(CreateTaskRequest $request, CreateTask $action): JsonResponse
    {
        $task = $action->execute(
            $request->toDto(),
            (string) $request->user()->tenant_id
        );

        return (new TaskResource($task))
            ->response()
            ->setStatusCode(201);
    }
}

See API Contracts for the full response format specification.


Backend: Routes

Register your endpoints in backend/routes/api.php, inside the tenant-scoped group:

backend/routes/api.php
// Inside the existing tenant-scoped middleware group:
Route::prefix('tenant/{tenantId}')
    ->middleware(['tenant.member', 'onboarding.complete'])
    ->group(function (): void {

        // ... existing routes (invoices, subscription, team, etc.) ...

        // Tasks
        Route::get('tasks', [TaskController::class, 'index'])
            ->name('tenant.tasks.index');
        Route::post('tasks', [TaskController::class, 'store'])
            ->name('tenant.tasks.store');
        Route::get('tasks/{task}', [TaskController::class, 'show'])
            ->name('tenant.tasks.show');
        Route::patch('tasks/{task}', [TaskController::class, 'update'])
            ->name('tenant.tasks.update');
        Route::delete('tasks/{task}', [TaskController::class, 'destroy'])
            ->name('tenant.tasks.destroy');
    });

The tenant.member middleware ensures the authenticated user belongs to the tenant. The onboarding.complete middleware ensures the tenant has completed the onboarding flow (plan selected, payment done).


Backend: Service Provider

If your feature depends on contracts (interfaces), bind them in a dedicated service provider:

backend/app/Providers/TaskServiceProvider.php
<?php

declare(strict_types=1);

namespace App\Providers;

use App\Domain\Tasks\Contracts\TaskNotifierInterface;
use App\Infrastructure\Tasks\Providers\EmailTaskNotifier;
use Illuminate\Support\ServiceProvider;

final class TaskServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(TaskNotifierInterface::class, EmailTaskNotifier::class);
    }
}

Register it in backend/bootstrap/providers.php:

backend/bootstrap/providers.php
return [
    App\Providers\AppServiceProvider::class,
    App\Providers\BillingServiceProvider::class,
    App\Providers\TeamServiceProvider::class,
    App\Providers\WebhookServiceProvider::class,
    App\Providers\TaskServiceProvider::class,  // Add your provider
];
If your feature is pure CRUD with no external integrations, you do not need a service provider. The container resolves concrete classes automatically.

Backend: Tests

Tests mirror the application architecture:

backend/tests/
├── Feature/Api/V1/Tenant/
│   └── TaskTest.php              # HTTP endpoint tests
└── Unit/Application/Tasks/
    ├── Actions/CreateTaskTest.php # Action unit tests
    └── Queries/ListTasksTest.php  # Query unit tests

Feature Test (HTTP)

Feature tests verify the full request lifecycle — routing, validation, authorization, response format:

backend/tests/Feature/Api/V1/Tenant/TaskTest.php
<?php

declare(strict_types=1);

namespace Tests\Feature\Api\V1\Tenant;

use App\Models\Task;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

final class TaskTest extends TestCase
{
    use RefreshDatabase;

    private User $user;
    private Tenant $tenant;

    protected function setUp(): void
    {
        parent::setUp();
        $this->tenant = Tenant::factory()->create();
        $this->user = User::factory()->for($this->tenant)->create();
    }

    public function test_list_returns_only_tenant_tasks(): void
    {
        Task::factory()->count(3)->for($this->tenant)->create();
        $otherTenant = Tenant::factory()->create();
        Task::factory()->count(2)->for($otherTenant)->create();

        $this->actingAs($this->user)
            ->getJson("/api/v1/tenant/{$this->tenant->id}/tasks")
            ->assertOk()
            ->assertJsonCount(3, 'data');
    }

    public function test_store_creates_task(): void
    {
        $this->actingAs($this->user)
            ->postJson("/api/v1/tenant/{$this->tenant->id}/tasks", [
                'title' => 'My first task',
            ])
            ->assertCreated()
            ->assertJsonPath('data.title', 'My first task')
            ->assertJsonPath('data.status', 'pending');
    }

    public function test_store_validates_required_fields(): void
    {
        $this->actingAs($this->user)
            ->postJson("/api/v1/tenant/{$this->tenant->id}/tasks", [])
            ->assertUnprocessable()
            ->assertJsonValidationErrors(['title']);
    }

    public function test_unauthenticated_request_returns_401(): void
    {
        $this->getJson("/api/v1/tenant/{$this->tenant->id}/tasks")
            ->assertUnauthorized();
    }
}
Always test tenant isolation — verify that a user from Tenant A cannot access Tenant B's data. This is the single most important test in a multi-tenant application.

See Testing for more patterns and the full testing strategy.


Frontend: Feature Module

Create the feature directory structure:

frontend/features/core/tasks/
├── index.ts            # Barrel exports (public API)
├── types.ts            # TypeScript types
├── schemas.ts          # Zod schemas
├── api/
│   ├── index.ts        # Re-exports useTasksApi
│   └── tasks.api.ts    # API methods
├── composables/
│   └── useTasks.ts     # Reactive composable
└── components/
    └── TaskList.vue     # UI component

Frontend: Schemas and Types

Zod schemas mirror the backend Resource shape. The API client automatically transforms snake_case keys from the API to camelCase, so your schemas use camelCase:

frontend/features/core/tasks/schemas.ts
import { z } from 'zod'

// ─── Enums ──────────────────────────────────────────────────────────────────

export const taskStatusSchema = z.enum([
  'pending',
  'in_progress',
  'completed',
  'cancelled',
])

// ─── Resource Schema ────────────────────────────────────────────────────────

export const taskSchema = z.object({
  id: z.string(),
  title: z.string(),
  description: z.string().nullable(),
  status: taskStatusSchema,
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime(),
})

// ─── List Response ──────────────────────────────────────────────────────────

export const taskListSchema = z.object({
  data: z.array(taskSchema),
  meta: z.object({
    currentPage: z.number(),
    lastPage: z.number(),
    perPage: z.number(),
    total: z.number(),
  }),
})

Types are derived from schemas — never define them manually:

frontend/features/core/tasks/types.ts
import type { z } from 'zod'
import type { taskSchema, taskStatusSchema, taskListSchema } from './schemas'

// ─── Derived Types ──────────────────────────────────────────────────────────

export type TaskStatus = z.infer<typeof taskStatusSchema>
export type Task = z.infer<typeof taskSchema>
export type TaskList = z.infer<typeof taskListSchema>

// ─── Input Types ────────────────────────────────────────────────────────────

export interface CreateTaskInput {
  title: string
  description?: string
}

See Validation (Zod) for the full schema conventions.


Frontend: API Module

The API module makes HTTP calls and validates responses with Zod:

frontend/features/core/tasks/api/tasks.api.ts
import { useApiClient } from '@common/api'
import { useCurrentTenant } from '@foundation/tenancy'
import { taskSchema, taskListSchema } from '../schemas'
import type { Task, TaskList, CreateTaskInput } from '../types'

export function useTasksApi() {
  const { get, post } = useApiClient()
  const { tenant } = useCurrentTenant()

  function tenantPath(path: string): string {
    const tenantId = tenant.value?.id
    if (!tenantId) {
      throw new Error('No tenant context available.')
    }
    return `/api/v1/tenant/${tenantId}${path}`
  }

  async function listTasks(): Promise<TaskList> {
    const response = await get<unknown>(tenantPath('/tasks'))
    return taskListSchema.parse(response)
  }

  async function createTask(input: CreateTaskInput): Promise<Task> {
    const response = await post<unknown>(tenantPath('/tasks'), input)
    const parsed = taskSchema.parse(response)
    return parsed
  }

  return { listTasks, createTask }
}

The barrel export re-exports the composable:

frontend/features/core/tasks/api/index.ts
export { useTasksApi } from './tasks.api'

Frontend: Composable

Composables provide reactive state to components. They wrap the API module and use useAuthenticatedAsyncData — a wrapper that forces server: false because auth cookies are not available during SSR:

frontend/features/core/tasks/composables/useTasks.ts
import { readonly } from 'vue'
import { useTasksApi } from '../api'
import type { TaskList } from '../types'

export function useTasks() {
  const api = useTasksApi()

  const { data, pending, error, refresh } = useAuthenticatedAsyncData<TaskList>(
    'tasks:list',
    () => api.listTasks()
  )

  const tasks = computed(() => data.value?.data ?? [])
  const meta = computed(() => data.value?.meta ?? null)

  return {
    tasks: readonly(tasks),
    meta: readonly(meta),
    isLoading: readonly(pending),
    error: readonly(error),
    refresh,
  }
}
Never use raw useAsyncData for authenticated endpoints. Always use useAuthenticatedAsyncData. Without it, SSR will silently return null and the client will not re-fetch.

Frontend: Barrel Export

The barrel file (index.ts) is the public API of your feature. Other features and pages import from here — never from internal paths:

frontend/features/core/tasks/index.ts
// ─── Types ──────────────────────────────────────────────────────────────────
export type { Task, TaskStatus, TaskList, CreateTaskInput } from './types'

// ─── Schemas ────────────────────────────────────────────────────────────────
export { taskSchema, taskStatusSchema, taskListSchema } from './schemas'

// ─── API ────────────────────────────────────────────────────────────────────
export { useTasksApi } from './api'

// ─── Composables ────────────────────────────────────────────────────────────
export { useTasks } from './composables/useTasks'

Other features import using the path alias:

import { useTasks, type Task } from '@core/tasks'

Frontend: Page and Component

Page

Create a page in the file-based router. Dashboard pages use the dashboard layout and the auth + tenant middleware:

frontend/app/pages/dashboard/tasks/index.vue
<script setup lang="ts">
import { useTasks } from '@core/tasks'

definePageMeta({
  layout: 'dashboard',
  middleware: ['auth', 'tenant'],
})

const { t } = useI18n()
const { tasks, isLoading } = useTasks()

useSeoMeta({
  title: () => t('tasks.title'),
})
</script>

<template>
  <div>
    <h1 class="text-2xl font-bold">
      {{ t('tasks.title') }}
    </h1>

    <UCard v-if="isLoading" class="mt-6">
      <USkeleton class="h-32" />
    </UCard>

    <UCard v-else class="mt-6">
      <UTable
        :data="tasks"
        :columns="[
          { key: 'title', label: t('tasks.columns.title') },
          { key: 'status', label: t('tasks.columns.status') },
          { key: 'createdAt', label: t('tasks.columns.created') },
        ]"
      />
    </UCard>
  </div>
</template>

Add a sidebar entry for your new page in the relevant layout component. Dashboard navigation items are defined in the sidebar configuration of the dashboard layout.


Frontend: i18n

Add translation keys for all four supported locales:

{
  "tasks": {
    "title": "Tasks",
    "create": "Create Task",
    "columns": {
      "title": "Title",
      "status": "Status",
      "created": "Created"
    },
    "status": {
      "pending": "Pending",
      "in_progress": "In Progress",
      "completed": "Completed",
      "cancelled": "Cancelled"
    }
  }
}

Backend translations go in backend/lang/{locale}/tasks.php:

backend/lang/en/tasks.php
<?php

return [
    'created' => 'Task created successfully.',
    'updated' => 'Task updated successfully.',
    'deleted' => 'Task deleted successfully.',
    'not_found' => 'Task not found.',
];

See Internationalization for the full i18n setup and conventions.


The Complete File List

Here is every file you create for a full-stack feature:

Backend

FilePurpose
Domain/Tasks/Enums/TaskStatus.phpBusiness states
Application/Tasks/DTO/CreateTaskData.phpInput DTO for creation
Application/Tasks/DTO/ListTasksFilters.phpInput DTO for listing
Application/Tasks/Actions/CreateTask.phpCreate use case
Application/Tasks/Queries/ListTasks.phpList use case
Http/Requests/Api/V1/Tenant/CreateTaskRequest.phpValidation + toDto()
Http/Requests/Api/V1/Tenant/ListTasksRequest.phpValidation + toDto()
Http/Resources/Api/V1/Tenant/TaskResource.phpJSON output (snake_case)
Http/Controllers/Api/V1/Tenant/TaskController.phpThin controller
routes/api.phpRoute registration (edit existing file)
tests/Feature/Api/V1/Tenant/TaskTest.phpHTTP endpoint tests

Frontend

FilePurpose
features/core/tasks/index.tsBarrel export
features/core/tasks/types.tsTypeScript types
features/core/tasks/schemas.tsZod schemas
features/core/tasks/api/tasks.api.tsAPI methods
features/core/tasks/api/index.tsAPI re-export
features/core/tasks/composables/useTasks.tsReactive composable
features/core/tasks/components/TaskList.vueUI component
app/pages/dashboard/tasks/index.vuePage
i18n/locales/en.jsonEnglish translations (edit existing)
i18n/locales/fr.jsonFrench translations (edit existing)
i18n/locales/es.jsonSpanish translations (edit existing)
i18n/locales/it.jsonItalian translations (edit existing)

Layer Dependency Rules

The frontend enforces strict layer dependencies:

product/     → can import → core/, foundation/, common/
core/        → can import → foundation/, common/
foundation/  → can import → common/ only
common/      → imports NOTHING from features/

Place your feature in the appropriate layer:

  • foundation/ — Auth, tenancy, entitlements (auto-imported, no API folder)
  • core/ — Business domains: billing, team, catalog, your new features
  • product/ — Product-specific features: onboarding, analytics, dashboards

ESLint rules enforce these boundaries. Cross-imports between core features are rejected except for catalog, which is a shared owner feature.

See Vertical Slices for the full architecture explanation.