hibit-dev/laravel9-ddd
Laravel 9 DDD is a starter package for building Domain-Driven Design applications in Laravel. It offers a modular structure that separates domain, application, and infrastructure layers, helping organize repositories, actions, and business logic with clean architecture principles.
This is a reference application, not a drop-in Composer dependency. There is no Hibit\Ddd\ServiceProvider you require into an existing app — you adopt it by cloning the repo (or composer create-project hibit-dev/laravel9-ddd my-app) and treating the layered src/ tree as your project skeleton. Everything below assumes that model.
Minimal steps:
git clone / template the repo, then composer install, copy .env.example to .env, php artisan key:generate.php artisan migrate --seed.composer.json and note the PSR-4 root mapped to src/ (e.g. "Src\\": "src/"). Then walk src/Domain → src/Application → src/Infrastructure and finally the provider in app/Providers (or src/Infrastructure/Providers) that binds repository interfaces to Eloquent implementations.Where to look first, in order:
routes/ — the HTTP entry points.First use case: trace a single existing flow (e.g. "create a resource") from route to database and back, drawing the four-layer chain on paper. Then reproduce that chain for your own first bounded context. That trace is the onboarding — the value of the repo is the convention, not the features.
Organize by bounded context, then by layer. Rather than one giant Domain/, Application/ pair, nest per context (src/Domain/Billing, src/Application/Billing, …). The repo's flat layering is the template; keep it scoped as the app grows so contexts don't leak into each other.
Interface in Domain, implementation in Infrastructure. The single most important pattern to preserve:
// src/Domain/Order/Repositories/OrderRepositoryInterface.php
interface OrderRepositoryInterface {
public function findById(OrderId $id): ?Order;
public function save(Order $order): void;
}
// src/Application/Order/Services/CreateOrderService.php
final class CreateOrderService {
public function __construct(private OrderRepositoryInterface $orders) {}
public function execute(CreateOrderDto $dto): OrderDto { /* ... */ }
}
// bind in a provider
$this->app->bind(OrderRepositoryInterface::class, EloquentOrderRepository::class);
Controllers depend on Application services; Application services depend on Domain interfaces; only Infrastructure knows about Eloquent, HTTP, queues, or mail.
Keep Eloquent models and Domain entities separate. The Eloquent model (src/Infrastructure/.../Models/Order.php) is a persistence detail. It hydrates a Domain entity through a small mapper. This is the pattern developers drop first under deadline pressure and regret — once controllers accept an Eloquent model, the Domain layer is decorative.
Application services own transactions and orchestration. Wrap multi-repository writes in DB::transaction() at the service layer, dispatch domain events after commit (DB::afterCommit / Event::dispatch outside the closure), and return DTOs, never entities with lazy relations attached.
Validate at the boundary, enforce invariants in the entity. A FormRequest validates shape and authorization; value objects and entity constructors throw Domain exceptions for business rules. Map those exceptions to HTTP responses in a single listener/exception handler rather than try/catching in every controller.
Add a context with a checklist, so nothing is forgotten: Domain entity + value objects + repository interface + exceptions → Application DTOs + service → Infrastructure Eloquent model, mapper, repository impl, provider binding, migration → Presentation controller + request + resource → feature test.
Testing split: pure PHPUnit unit tests for Domain (no Illuminate\*, no DB — they should run in milliseconds); RefreshDatabase integration tests for Infrastructure repositories; HTTP feature tests for Presentation. That triad is the practical payoff of the layering, and it's worth enforcing in CI early.
php artisan make:model puts files in app/Models with App\ namespaces. If you then move them under src/Infrastructure, update the namespace line and run composer dump-autoload -o. Half of all "class not found" issues in this layout are stale autoload maps.app/, set protected $table explicitly and make sure database/migrations still resolves. Migrations referencing App\Models\... after a move will break silently on migrate:fresh.bind gives a fresh instance per resolution; singleton in a long-running worker (Octane, queue worker) will serve stale state — including cached aggregates. Use scoped for request/job-lifetime collaborators and audit any repository that caches internally.Illuminate\Support\Collection in Domain signatures, ->load(), ->toArray(), and Carbon in entities. If Domain code uses anything from Illuminate\, you've lost the testability argument. A one-line architectural test (grep for Illuminate\\ under src/Domain) in CI is cheap insurance.AbstractEloquentRepository for shared CRUD plumbing, and an app/Support folder for cross-cutting helpers (transaction wrapper, exception mapper, DTO serializer). Those three give you leverage without compromising the layering.How can I help you explore Laravel packages today?