Adding a Feature
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 entity | Application/<Domain>/Actions/ |
| List, filter, or paginate | Application/<Domain>/Queries/ |
| Define input data for a use case | Application/<Domain>/DTO/*Data.php |
| Define an interface for an external service | Domain/<Domain>/Contracts/ |
| Create an enum or value object | Domain/<Domain>/Enums/ or ValueObjects/ |
| Implement an external integration | Infrastructure/<Domain>/Providers/<Provider>/ |
| Transform an external response | Infrastructure/<Domain>/Mappers/ |
| Validate HTTP input | Http/Requests/Api/V1/<Context>/ |
| Format JSON output | Http/Resources/Api/V1/<Context>/ |
| Add a reusable business rule | Domain/<Domain>/Rules/ |
Naming Conventions
| Element | Convention | Example |
|---|---|---|
| Action | Business verb, no suffix | CreateTask, CompleteTask |
| Query | Read verb, no suffix | ListTasks, FindTaskById |
| Input DTO | *Data or *Filters | CreateTaskData, ListTasksFilters |
| Transfer DTO | Provider-agnostic | ExternalTask |
| Resource | Singular noun | TaskResource |
| Frontend composable | use<Feature>() | useTasks() |
| Frontend API module | use<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:
<?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);
}
}
Contract (Optional)
If your feature needs to call an external service, define a contract in the Domain. The Infrastructure layer provides the implementation.
<?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:
<?php
declare(strict_types=1);
namespace App\Application\Tasks\DTO;
final readonly class CreateTaskData
{
public function __construct(
public string $title,
public ?string $description = null,
) {}
}
<?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:
<?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:
<?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:
<?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'),
);
}
}
<?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:
<?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(),
];
}
}
Controller
Controllers are thin — they validate, delegate, and return:
<?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:
// 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:
<?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:
return [
App\Providers\AppServiceProvider::class,
App\Providers\BillingServiceProvider::class,
App\Providers\TeamServiceProvider::class,
App\Providers\WebhookServiceProvider::class,
App\Providers\TaskServiceProvider::class, // Add your provider
];
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:
<?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();
}
}
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:
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:
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:
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:
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:
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,
}
}
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:
// ─── 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:
<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>
Navigation
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"
}
}
}
{
"tasks": {
"title": "Tâches",
"create": "Créer une tâche",
"columns": {
"title": "Titre",
"status": "Statut",
"created": "Créé"
},
"status": {
"pending": "En attente",
"in_progress": "En cours",
"completed": "Terminé",
"cancelled": "Annulé"
}
}
}
Backend translations go in backend/lang/{locale}/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
| File | Purpose |
|---|---|
Domain/Tasks/Enums/TaskStatus.php | Business states |
Application/Tasks/DTO/CreateTaskData.php | Input DTO for creation |
Application/Tasks/DTO/ListTasksFilters.php | Input DTO for listing |
Application/Tasks/Actions/CreateTask.php | Create use case |
Application/Tasks/Queries/ListTasks.php | List use case |
Http/Requests/Api/V1/Tenant/CreateTaskRequest.php | Validation + toDto() |
Http/Requests/Api/V1/Tenant/ListTasksRequest.php | Validation + toDto() |
Http/Resources/Api/V1/Tenant/TaskResource.php | JSON output (snake_case) |
Http/Controllers/Api/V1/Tenant/TaskController.php | Thin controller |
routes/api.php | Route registration (edit existing file) |
tests/Feature/Api/V1/Tenant/TaskTest.php | HTTP endpoint tests |
Frontend
| File | Purpose |
|---|---|
features/core/tasks/index.ts | Barrel export |
features/core/tasks/types.ts | TypeScript types |
features/core/tasks/schemas.ts | Zod schemas |
features/core/tasks/api/tasks.api.ts | API methods |
features/core/tasks/api/index.ts | API re-export |
features/core/tasks/composables/useTasks.ts | Reactive composable |
features/core/tasks/components/TaskList.vue | UI component |
app/pages/dashboard/tasks/index.vue | Page |
i18n/locales/en.json | English translations (edit existing) |
i18n/locales/fr.json | French translations (edit existing) |
i18n/locales/es.json | Spanish translations (edit existing) |
i18n/locales/it.json | Italian 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 featuresproduct/— 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.
Prompt Patterns
Reusable patterns for structuring AI agent context: workplan templates, code templates, agent personas, acceptance criteria conventions, and the reference-copy approach that keeps AI output consistent.
Custom Billing Logic
How to extend the billing system: adding pricing models, preparing for new payment providers, customizing checkout, and working within V1 constraints.