Documentation

Laravel DDD Toolkit
field guide.

A practical guide for Laravel developers: what each generated folder means, when a concept is useful, which Artisan command creates it, and how it compares with familiar Laravel patterns.

Vertical modules Hexagonal by default Pragmatic tactical DDD

Mental model

Laravel you already know
Controller
β†’ Service
β†’ Repository
β†’ Eloquent Model
With this toolkit
Controller
β†’ Application Use Case
β†’ Domain behavior
β†’ Port
β†’ Infrastructure Adapter

Installation

composer require samuel-nunes/laravel-ddd-toolkit
php artisan ddd:install

Default generated module structure

app/Modules/Order/
β”œβ”€β”€ Domain/
β”‚   β”œβ”€β”€ Entities/
β”‚   β”œβ”€β”€ ValueObjects/
β”‚   β”œβ”€β”€ Events/
β”‚   └── Exceptions/
β”œβ”€β”€ Application/
β”‚   β”œβ”€β”€ UseCases/
β”‚   β”œβ”€β”€ DTO/
β”‚   └── Ports/
β”‚       β”œβ”€β”€ In/
β”‚       └── Out/
└── Infrastructure/
    β”œβ”€β”€ Http/
    β”‚   β”œβ”€β”€ Controllers/
    β”‚   β”œβ”€β”€ Requests/
    β”‚   └── routes.php
    β”œβ”€β”€ Persistence/
    β”‚   β”œβ”€β”€ Models/
    β”‚   └── Adapters/
    β”œβ”€β”€ Integrations/
    └── Providers/

app/Shared/
β”œβ”€β”€ Domain/
β”œβ”€β”€ Application/
└── Infrastructure/

Shared folder

Path
app/Shared/
β”œβ”€β”€ Domain/
β”œβ”€β”€ Application/
└── Infrastructure/
Example use
app/Shared/Domain/ValueObjects/Email.php
app/Shared/Domain/ValueObjects/Cpf.php
app/Shared/Application/Clock/SystemClock.php
app/Shared/Infrastructure/Support/UuidGenerator.php
When should I use it?

Module

Path
app/Modules/Billing/
Generate with
php artisan make:module Billing
Why use it?

README.md and AGENTS.md per module

Generate with
php artisan make:module Billing
php artisan make:module Billing --context="Handles invoice issuing, cancellation and invoice queries."
php artisan make:module Billing --context-file=docs/billing-context.md
php artisan make:module Billing --no-ai-docs
Generated files
app/Modules/Billing/README.md
app/Modules/Billing/AGENTS.md

Domain Entity

Path
app/Modules/Billing/Domain/Entities/Invoice.php
Generate with
# Billing = module
# Invoice = entity/resource name
php artisan make:entity Billing Invoice
Example implementation
<?php

namespace App\Modules\Billing\Domain\Entities;

use App\Modules\Billing\Domain\Events\InvoiceIssued;
use App\Modules\Billing\Domain\Exceptions\InvoiceCannotBeIssued;
use App\Modules\Billing\Domain\ValueObjects\Money;

final class Invoice
{
    private array $items = [];
    private array $events = [];

    private function __construct(
        private readonly string $id,
        private Money $total,
        private InvoiceStatus $status,
    ) {}

    public static function draft(string $id): self
    {
        return new self($id, new Money(0, 'BRL'), InvoiceStatus::draft());
    }

    public function addItems(array $items): void
    {
        foreach ($items as $item) {
            $this->addItem($item);
        }
    }

    private function addItem(InvoiceItem $item): void
    {
        if (! $this->status->isDraft()) {
            throw InvoiceCannotBeIssued::becauseItIsNotDraft($this->id);
        }

        $this->items[] = $item;
        $this->total = $this->total->add($item->subtotal());
    }

    public function issue(): void
    {
        if ($this->items === []) {
            throw InvoiceCannotBeIssued::withoutItems($this->id);
        }

        $this->status = InvoiceStatus::issued();
        $this->events[] = new InvoiceIssued($this->id, (new \DateTimeImmutable())->format(DATE_ATOM));
    }

    public function pullDomainEvents(): array
    {
        $events = $this->events;
        $this->events = [];

        return $events;
    }
}

Value Object

Path
app/Modules/Billing/Domain/ValueObjects/Money.php
Generate with
# Billing = module
# Money = value object/resource name
php artisan make:value-object Billing Money
Example implementation
<?php

namespace App\Modules\Billing\Domain\ValueObjects;

use InvalidArgumentException;

final readonly class Money
{
    public function __construct(
        public int $amountInCents,
        public string $currency = 'BRL',
    ) {
        if ($amountInCents < 0) {
            throw new InvalidArgumentException('Money amount cannot be negative.');
        }
    }

    public function add(self $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new InvalidArgumentException('Cannot add different currencies.');
        }

        return new self($this->amountInCents + $other->amountInCents, $this->currency);
    }
}
How to use it
$lineTotal = new Money(12990, 'BRL');
$shipping = new Money(1500, 'BRL');

$total = $lineTotal->add($shipping);

$invoice->addItem(new InvoiceItem(
    description: 'Laravel DDD Toolkit license',
    subtotal: $total,
));

Domain Event

Generate with
# Billing = module
# InvoiceIssued = domain event/resource name
php artisan make:event Billing InvoiceIssued
Event object
<?php

namespace App\Modules\Billing\Domain\Events;

final readonly class InvoiceIssued
{
    public function __construct(
        public string $invoiceId,
        public string $issuedAt,
    ) {}
}
How to use it
// Domain/Entities/Invoice.php
public function issue(): void
{
    if ($this->items === []) {
        throw InvoiceCannotBeIssued::withoutItems($this->id);
    }

    $this->status = InvoiceStatus::issued();
    $this->events[] = new InvoiceIssued($this->id, (new \DateTimeImmutable())->format(DATE_ATOM));
}

public function pullDomainEvents(): array
{
    $events = $this->events;
    $this->events = [];

    return $events;
}
Dispatch from the use case
// Application/UseCases/IssueInvoice/IssueInvoiceHandler.php
public function handle(IssueInvoiceData $data): Invoice
{
    $invoice = Invoice::draft($data->customerId);
    $invoice->addItems($data->items);
    $invoice->issue();

    $this->invoices->save($invoice);

    foreach ($invoice->pullDomainEvents() as $event) {
        event($event); // Laravel event dispatcher at the application edge
    }

    return $invoice;
}

Domain Exception

Path
app/Modules/Billing/Domain/Exceptions/InvoiceCannotBeIssued.php
Example implementation
<?php

namespace App\Modules\Billing\Domain\Exceptions;

use DomainException;

final class InvoiceCannotBeIssued extends DomainException
{
    public static function withoutItems(string $invoiceId): self
    {
        return new self("Invoice {$invoiceId} cannot be issued without items.");
    }

    public static function becauseItIsNotDraft(string $invoiceId): self
    {
        return new self("Invoice {$invoiceId} cannot be changed after leaving draft status.");
    }
}
How to use it
// Domain/Entities/Invoice.php
public function issue(): void
{
    if ($this->items === []) {
        throw InvoiceCannotBeIssued::withoutItems($this->id);
    }

    $this->status = InvoiceStatus::issued();
}

// Infrastructure/Http/Controllers/IssueInvoiceController.php
try {
    $invoice = $useCase->handle($data);
} catch (InvoiceCannotBeIssued $exception) {
    abort(422, $exception->getMessage());
}

Application Use Case

Generate with
# Billing = module
# IssueInvoice = use case/resource name
php artisan make:usecase Billing IssueInvoice
Example implementation
<?php

namespace App\Modules\Billing\Application\UseCases\IssueInvoice;

use App\Modules\Billing\Application\DTO\IssueInvoiceData;
use App\Modules\Billing\Application\Ports\In\IssueInvoiceUseCase;
use App\Modules\Billing\Application\Ports\Out\InvoiceRepository;
use App\Modules\Billing\Domain\Entities\Invoice;

final readonly class IssueInvoiceHandler implements IssueInvoiceUseCase
{
    public function __construct(private InvoiceRepository $invoices) {}

    public function handle(IssueInvoiceData $data): Invoice
    {
        $invoice = Invoice::draft($data->customerId);

        $invoice->addItems($data->items);
        $invoice->issue();
        $this->invoices->save($invoice);

        return $invoice;
    }
}

Application DTO

Path
app/Modules/Billing/Application/DTO/IssueInvoiceData.php
Example implementation
<?php

namespace App\Modules\Billing\Application\DTO;

final readonly class IssueInvoiceData
{
    public function __construct(
        public string $customerId,
        public array $items,
    ) {}
}
How it differs from Resource
public function __invoke(IssueInvoiceRequest $request, IssueInvoiceUseCase $useCase): InvoiceResource
{
    $invoice = $useCase->handle(new IssueInvoiceData(
        customerId: $request->string('customer_id')->toString(),
        items: InvoiceItemInputMapper::fromRequest($request),
    ));

    return new InvoiceResource($invoice);
}

Application Port In

Generate with
# Billing = module
# IssueInvoiceUseCase = inbound port/resource name
php artisan make:port Billing IssueInvoiceUseCase --type=in
Interface
<?php

namespace App\Modules\Billing\Application\Ports\In;

use App\Modules\Billing\Application\DTO\IssueInvoiceData;
use App\Modules\Billing\Domain\Entities\Invoice;

interface IssueInvoiceUseCase
{
    public function handle(IssueInvoiceData $data): Invoice;
}
Service container usage
// Infrastructure/Providers/BillingServiceProvider.php
$this->app->bind(
    IssueInvoiceUseCase::class,
    IssueInvoiceHandler::class,
);

// Infrastructure/Http/Controllers/IssueInvoiceController.php
public function __invoke(IssueInvoiceRequest $request, IssueInvoiceUseCase $useCase): InvoiceResource
{
    return new InvoiceResource($useCase->handle(
        IssueInvoiceData::fromRequest($request)
    ));
}

Application Port Out

Generate with
# Billing = module
# InvoiceRepository = outbound port/resource name
php artisan make:port Billing InvoiceRepository --type=out
Interface
<?php

namespace App\Modules\Billing\Application\Ports\Out;

use App\Modules\Billing\Domain\Entities\Invoice;

interface InvoiceRepository
{
    public function findById(string $id): ?Invoice;
    public function save(Invoice $invoice): void;
}
Why it is useful
final readonly class IssueInvoiceHandler
{
    public function __construct(private InvoiceRepository $invoices) {}

    public function handle(IssueInvoiceData $data): Invoice
    {
        // The use case does not know if persistence is Eloquent, API, cache, legacy DB or tests.
        $invoice = Invoice::draft($data->customerId);
        $invoice->issue();

        $this->invoices->save($invoice);

        return $invoice;
    }
}
Can I just use Eloquent directly?
// For simple CRUD, yes. The toolkit does not force repositories.
// This is fine when there is no rich domain behavior to protect:
InvoiceModel::query()->create($request->validated());

// But inside a hexagonal Application use case, prefer a Port Out:
// - the use case does not import Eloquent;
// - tests can bind an in-memory implementation;
// - persistence mapping stays in Infrastructure.

Infrastructure HTTP Controller

Example implementation
<?php

namespace App\Modules\Billing\Infrastructure\Http\Controllers;

use App\Modules\Billing\Application\DTO\IssueInvoiceData;
use App\Modules\Billing\Application\Ports\In\IssueInvoiceUseCase;
use App\Modules\Billing\Infrastructure\Http\Requests\IssueInvoiceRequest;
use App\Modules\Billing\Infrastructure\Http\Resources\InvoiceResource;

final class IssueInvoiceController
{
    public function __invoke(IssueInvoiceRequest $request, IssueInvoiceUseCase $useCase): InvoiceResource
    {
        return new InvoiceResource($useCase->handle(
            IssueInvoiceData::fromRequest($request)
        ));
    }
}

Infrastructure HTTP Request

Example implementation
<?php

namespace App\Modules\Billing\Infrastructure\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

final class IssueInvoiceRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'customer_id' => ['required', 'string'],
            'items' => ['required', 'array', 'min:1'],
        ];
    }
}

Infrastructure Eloquent Model

Example implementation
<?php

namespace App\Modules\Billing\Infrastructure\Persistence\Models;

use Illuminate\Database\Eloquent\Model;

final class InvoiceModel extends Model
{
    protected $table = 'invoices';

    protected $casts = [
        'total_amount' => 'integer',
        'issued_at' => 'datetime',
    ];
}

Infrastructure Adapter

Generate with
# Billing = module
# EloquentInvoiceRepository = adapter/resource name
# --port=InvoiceRepository = port implemented by the adapter
php artisan make:adapter Billing EloquentInvoiceRepository --port=InvoiceRepository --type=persistence
Example implementation
<?php

namespace App\Modules\Billing\Infrastructure\Persistence\Adapters;

use App\Modules\Billing\Application\Ports\Out\InvoiceRepository;
use App\Modules\Billing\Domain\Entities\Invoice;
use App\Modules\Billing\Infrastructure\Persistence\Models\InvoiceModel;
use App\Modules\Billing\Infrastructure\Persistence\Mappers\InvoiceMapper;

final class EloquentInvoiceRepository implements InvoiceRepository
{
    public function findById(string $id): ?Invoice
    {
        $model = InvoiceModel::query()->find($id);

        return $model ? InvoiceMapper::toDomain($model) : null;
    }

    public function save(Invoice $invoice): void
    {
        InvoiceModel::query()->updateOrCreate(
            ['id' => $invoice->id()],
            InvoiceMapper::toPersistence($invoice),
        );
    }
}

Persistence Mapper

Path
app/Modules/Billing/Infrastructure/Persistence/Mappers/InvoiceMapper.php
Example implementation
<?php

namespace App\Modules\Billing\Infrastructure\Persistence\Mappers;

use App\Modules\Billing\Domain\Entities\Invoice;
use App\Modules\Billing\Domain\Entities\InvoiceStatus;
use App\Modules\Billing\Domain\ValueObjects\Money;
use App\Modules\Billing\Infrastructure\Persistence\Models\InvoiceModel;

final class InvoiceMapper
{
    public static function toDomain(InvoiceModel $model): Invoice
    {
        return Invoice::restore(
            id: (string) $model->id,
            total: new Money((int) $model->total_amount, $model->currency),
            status: InvoiceStatus::from($model->status),
            items: InvoiceItemMapper::collectionToDomain($model->items),
        );
    }

    public static function toPersistence(Invoice $invoice): array
    {
        return [
            'id' => $invoice->id(),
            'total_amount' => $invoice->total()->amountInCents,
            'currency' => $invoice->total()->currency,
            'status' => $invoice->status()->value,
        ];
    }
}

Infrastructure Integration

Generate with
# Payment = module
# Stripe = integration/resource name
php artisan make:integration Payment Stripe
Generated files
app/Modules/Payment/Infrastructure/Integrations/Stripe/
β”œβ”€β”€ DTO/
β”œβ”€β”€ Exceptions/
β”œβ”€β”€ StripeClient.php
β”œβ”€β”€ StripeAdapter.php
└── StripeMapper.php
Example use
// Payment = module
// Stripe = integration name

$this->app->bind(
    PaymentGateway::class,
    StripeAdapter::class,
);

Infrastructure Provider

Path
app/Modules/Billing/Infrastructure/Providers/BillingServiceProvider.php
Example implementation
<?php

namespace App\Modules\Billing\Infrastructure\Providers;

use App\Modules\Billing\Application\Ports\In\IssueInvoiceUseCase;
use App\Modules\Billing\Application\Ports\Out\InvoiceRepository;
use App\Modules\Billing\Application\UseCases\IssueInvoice\IssueInvoiceHandler;
use App\Modules\Billing\Infrastructure\Persistence\Adapters\EloquentInvoiceRepository;
use Illuminate\Support\ServiceProvider;

final class BillingServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(IssueInvoiceUseCase::class, IssueInvoiceHandler::class);
        $this->app->bind(InvoiceRepository::class, EloquentInvoiceRepository::class);

        // It can also bind external gateways, clients, mappers, config-driven services,
        // or module-specific implementations that should stay close to Billing.
    }
}
Without a repository
public function register(): void
{
    // Inbound port to use case handler.
    $this->app->bind(IssueInvoiceUseCase::class, IssueInvoiceHandler::class);

    // External service port to integration adapter.
    $this->app->bind(FiscalGateway::class, SefazFiscalGateway::class);

    // A configured client that belongs only to this module.
    $this->app->singleton(SefazClient::class, fn () => new SefazClient(
        baseUrl: config('services.sefaz.url'),
        token: config('services.sefaz.token'),
    ));
}

Repository

Generate with
# Billing = module
# InvoiceRepository = repository/resource name
# --force = generate even when repositories are disabled by default
php artisan make:repository Billing InvoiceRepository --force
Path
app/Modules/Billing/Infrastructure/Persistence/Repositories/InvoiceRepository.php
When it makes sense
// Good reasons for a repository:
// - complex read/write persistence hidden from the use case
// - legacy database tables
// - stored procedures
// - persistence that mixes SQL + cache
// - reports or read models with long query composition
// - a future swap from Eloquent to another storage mechanism

// Weak reason:
// - creating find(), create(), update() wrappers for every Eloquent model

Day-to-day cases

// Keep it simple
InvoiceModel::query()->create($request->validated());

$invoice = Invoice::draft($customerId);
$invoice->addItems($items);
$invoice->issue();

interface FiscalGateway
{
    public function authorize(Invoice $invoice): FiscalAuthorization;
}

IssueInvoiceController β†’ IssueInvoiceUseCase
IssueInvoiceJob        β†’ IssueInvoiceUseCase

// Input to the use case: DTO
// Output to HTTP clients: Laravel Resource

UseCase β†’ InvoiceRepository port
Adapter β†’ Eloquent + InvoiceMapper

Philosophy and software practices

Architecture checks and discovery cache

Run
php artisan ddd:check
php artisan ddd:check --module=Billing
php artisan ddd:cache
php artisan ddd:clear

How to read this documentation

For Laravel Developers β†’