Building a Laravel API that stays true to domain-driven design (DDD) and clean architecture can feel like a juggling act. Between keeping the domain pure, offering a friendly HTTP layer, and wiring infrastructure concerns, the implementation tends to go sideways fast. This walkthrough captures how we delivered a new Inventory Item feature (register + fetch) on Laravel 12/PHP 8.4 with:
- four layered architecture (Domain → Application → Infrastructure → Interfaces/Http),
- Sanctum-protected endpoints and policies,
- write idempotency and rate limiting,
- eager‑loading friendly repositories (no Model::all()),
- Pest-powered unit + feature tests.
Below is the journey, the folder layout, and the reasoning behind each layer so you can reuse (or remix) the approach in your own projects.
1. The Domain Core: Entities, Value Objects, Policies
Goal: Express ubiquitous language and rules without accidentally smuggling Laravel in.
Key pieces live in app/Domain/Inventory/*:
- 
InventoryItemaggregate root enforces invariants (non-empty name, non-negative stock, discontinued ⇒ zero quantity) inside factory methods.
- Value Objects (Sku,Price) encapsulate formatting, normalization, and equality so the rest of the codebase never touches raw strings.
- 
InventoryItemStatusenum replaces stringly-typed state and keeps filtering safe.
- Repository interface (InventoryItemRepositoryInterface) returns domain entities orPaginatedInventoryItems(a tiny pagination DTO) so application code doesn’t leakEloquent.
- Domain exceptions (InventoryItemNotFound,DuplicateSkuException,IdempotencyConflictException) standardize failure semantics.
By keeping the domain tree framework-agnostic, tests/specifications read almost like business rules and stay fast.
2. Application Layer: Orchestration & DTOs
Goal: Coordinate use cases with minimal knowledge about delivery or storage.
In app/Application/Inventory we added:
- 
CreateInventoryItemCommand(immutable input DTO) – the use case accepts a fully-formed command object.
- 
RegisterInventoryItemResult+InventoryItemData– the use case returns a serializable DTO, not the entity, preventing lazy loading accidents later.
- 
RegisterInventoryItemuse case – orchestrates validation, repository calls, idempotency lookups, and stores new items within a transaction.
- Supporting contracts (ClockInterface,TransactionManagerInterface,IdempotencyServiceInterface) andProcessedIdempotencyResultDTO ensure we can swap infrastructure implementations.
Notice the use case itself remains framework-neutral but leverages the contracts it needs:
public function __invoke(CreateInventoryItemCommand $command): RegisterInventoryItemResult
{
    return $this->transactionManager->run(function () use ($command) {
        if ($stored = $this->idempotencyService->retrieve($command->userId, $command->idempotencyKey)) {
            if ($stored->requestHash !== $command->payloadHash) {
                throw IdempotencyConflictException::fromKey($command->idempotencyKey);
            }
            return new RegisterInventoryItemResult(
                InventoryItemData::fromArray($stored->responseBody),
                true,
                $stored->statusCode,
            );
        }
        // Prevent duplicate SKUs inside the transaction guard
        if ($this->repository->findBySku(Sku::fromString($command->sku))) {
            throw DuplicateSkuException::withSku($command->sku);
        }
        $item = InventoryItem::create(
            $command->id,
            Sku::fromString($command->sku),
            $command->name,
            $command->description,
            $command->quantity,
            $command->reorderLevel,
            Price::fromDecimal($command->price, $command->currency),
            InventoryItemStatus::from($command->status),
            $this->clock->now(),
        );
        $this->repository->save($item);
        $result = InventoryItemData::fromEntity($item);
        $this->idempotencyService->store(
            $command->userId,
            $command->idempotencyKey,
            $command->payloadHash,
            201,
            $result->toArray(),
        );
        return new RegisterInventoryItemResult($result, false, 201);
    });
}
3. Infrastructure Layer: Laravel-powered Adapters
Goal: Provide concrete implementations for persistence and system concern contracts without leaking Eloquent upward.
Highlights:
- 
InventoryItemModelis a plain Eloquent model + factory, but we overridenewFactory()to hook the custom factory and keep the naming consistent.
- 
InventoryItemRepositoryconvertsInventoryItemModel↔ domain objects, handles pagination with user-controlled filters and sorts, and updates/creates records without calling->all().
- 
DatabaseTransactionManager,SystemClock,DatabaseIdempotencyServicefulfill the contracts used by the application layer.
- Container bindings in AppServiceProviderwire everything together.
public function register(): void
{
    $this->app->bind(InventoryItemRepositoryInterface::class, InventoryItemRepository::class);
    $this->app->bind(TransactionManagerInterface::class, DatabaseTransactionManager::class);
    $this->app->bind(ClockInterface::class, SystemClock::class);
    $this->app->bind(IdempotencyServiceInterface::class, DatabaseIdempotencyService::class);
}
and the idempotency implementation uses the dedicated table (idempotency_keys) to replay identical requests instantly.
4. Interfaces / HTTP: Requests, Controllers, Policies, Routes
Goal: Keep controllers thin, validate early, map exceptions to HTTP codes, and protect the API surface.
Main pieces in app/Interfaces/Http:
- 
StoreInventoryItemRequesthandles validation, computes an SHA-256 payload hash, and ensures theIdempotency-Keyheader is present. It also authorizes via theInventoryItemPolicy.
- 
FilterInventoryItemsRequestparses filter/search/sort/pagination parameters into a single DTO-like array.
- 
InventoryItemControlleradapts HTTP requests to application commands/DTOs and returnsInventoryItemResourceresponses.
- 
TransformDomainExceptionsmiddleware catches known domain exceptions and converts them into JSON responses with HTTP codes (422/404/409).
- 
InventoryItemPolicycontrolsview,viewAny,createabilities using Sanctum token abilities (inventory:read,inventory:write).
- 
routes/api.phpregisters theinventory-itemsresource with Sanctum auth, throttle, and the middleware.
We also added framework-level exception rendering (in bootstrap/app.php) to map domain exceptions to JSON responses globally – this keeps feature tests expressive without repeating try/catch blocks.
$exceptions->render(function (DuplicateSkuException|IdempotencyConflictException $exception, Request $request) {
    if ($request->expectsJson()) {
        return new JsonResponse(['message' => $exception->getMessage()], 409);
    }
    return null;
});
5. Auth, Policies & Rate Limiting
- Sanctum is required for every HTTP route (Route::middleware('auth:sanctum', ...)) and theUsermodel now usesHasApiTokensagain.
- 
AppServiceProvider::boot()registers a rate limiter (inventory-items) and mapsInventoryItemModel→InventoryItemPolicy.
- Each test uses Sanctum::actingAs()with the proper abilities.
This means even in feature tests, the policy is enforced and we get a natural 403 if a token lacks inventory:write.
6. Database Schema & Seeding
Two migrations power the infrastructure:
- 
inventory_itemstable – UUID primary key, indexed SKU + status, currency + amount columns, timestamps.
- 
idempotency_keystable – stores(user, key, request_hash, response)to replay or detect conflicting requests.
The factory + seeder (InventoryItemSeeder) allow quick data population:
final class InventoryItemSeeder extends Seeder
{
    public function run(): void
    {
        InventoryItemModel::factory()->count(20)->create();
    }
}
DatabaseSeeder calls this seeder after creating a demo user.
7. Tests: Pest for Unit + Feature Coverage
We rely on Pest to keep tests terse:
- 
Unit tests focus on value objects (Sku,Price) and theRegisterInventoryItemuse case (including idempotency replay and duplicate SKU detection).
- 
Feature tests (tests/Feature/Http/Inventory/InventoryItemApiTest.php) run against a refreshed database, authenticate via Sanctum, and assert:- successful registration returns 201 with the DTO payload and Idempotent-Replay: falseheader,
- replay with the same idempotency key returns 200 and Idempotent-Replay: true,
- validation errors yield 422,
- duplicate SKUs produce a 409 (after our exception handler addition),
- fetching a missing ID triggers a 404 courtesy of domain exception mapping.
 
- successful registration returns 201 with the DTO payload and 
Every feature test resets the DB via RefreshDatabase, so the schema is migrated and clean each run.
8. Lessons Learned & Next Steps
- Domain boundaries matter – once entities and value objects are pure, testing and refactoring become painless.
- 
Contracts > Facades in the application layer – relying on interfaces made the RegisterInventoryItemuse case testable without mocking Laravel at all.
- Middleware + global exception rendering keeps controllers slim and pushes error semantics to the edge.
- Idempotency is worth the extra table – clients (mobile/web) can safely retry POSTs without spamming duplicates.
- Policies + abilities keep the HTTP layer enforceable; remember to register them in a service provider.
Ideas to extend:
- Introduce more domain events (e.g., InventoryItemRegistered) to notify other bounded contexts asynchronously.
- Add PATCH endpoints for stock adjustments with transactional locking.
- Swap the idempotency layer to Redis when traffic grows to reduce table contention.
- Layer in CQRS read models (e.g., inventory_items_view) or caching for list endpoints.
Closing Thoughts
This pattern isn’t just academic DDD – it scales your API both technically and organizationally. By isolating domain rules, orchestrating via application services, and treating Laravel as the infrastructure/driver, you get a codebase that’s easier to maintain, test, and evolve.
If you adopt similar layers, reach out with tweaks or enhancements. Happy shipping! 🚢
 

 
    
Top comments (1)
Can some feature Pest test be executed within a transaction? And wouldn't it be faster to rollback transactions after each test instead of building DB from scratch?