// Keep it simple
InvoiceModel::query()->create($request->validated());
Documentation
Laravel DDD Toolkit
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/
Module
Path
app/Modules/Billing/Generate with
php artisan make:module BillingWhy 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-docsGenerated files
app/Modules/Billing/README.md
app/Modules/Billing/AGENTS.mdDomain Entity
Path
app/Modules/Billing/Domain/Entities/Invoice.phpGenerate with
# Billing = module
# Invoice = entity/resource name
php artisan make:entity Billing InvoiceExample 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.phpGenerate with
# Billing = module
# Money = value object/resource name
php artisan make:value-object Billing MoneyExample 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 InvoiceIssuedEvent 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.phpExample 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 IssueInvoiceExample 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.phpExample 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=inInterface
<?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=outInterface
<?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=persistenceExample 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.phpExample 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 StripeGenerated files
app/Modules/Payment/Infrastructure/Integrations/Stripe/
βββ DTO/
βββ Exceptions/
βββ StripeClient.php
βββ StripeAdapter.php
βββ StripeMapper.phpExample use
// Payment = module
// Stripe = integration name
$this->app->bind(
PaymentGateway::class,
StripeAdapter::class,
);Infrastructure Provider
Path
app/Modules/Billing/Infrastructure/Providers/BillingServiceProvider.phpExample 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 --forcePath
app/Modules/Billing/Infrastructure/Persistence/Repositories/InvoiceRepository.phpWhen 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 modelDay-to-day cases
$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 ResourceUseCase β InvoiceRepository port
Adapter β Eloquent + InvoiceMapperPhilosophy 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