
September 08, 2026
12 min read
By Kokil Thapa | Last reviewed: September 2026
Most Laravel apps start tidy and collapse into fat controllers within a year. Clean Architecture in Laravel real example code shows a fix: keep business rules in plain PHP classes and treat Laravel as delivery infrastructure. On legal-tech and booking portals I have shipped, that split saved weeks during payment-gateway swaps and Laravel architecture upgrades. This guide walks through a document-booking flow you can paste into a Laravel 12 or 13 project on PHP 8.3+.
What Is Clean Architecture in Laravel and Why Use It?
Clean Architecture, popularised by Robert C. Martin, organises code into concentric rings. The inner ring holds enterprise rules. Outer rings handle UI, databases, and third-party APIs. Dependencies point inward only. Laravel already gives you controllers, models, and views. The mistake is letting all three own business logic.
In practice, a booking portal should not decide cancellation policy inside a controller. That rule belongs in a use-case class any adapter can call. You gain testable domain code, swappable persistence, and clearer boundaries for teams. The trade-off is more folders and interfaces. For a five-page brochure site, skip it. For a multi-module enterprise Laravel application, the structure pays back quickly.
The goal is not academic purity. You want code a new developer can change without breaking unrelated features. That aligns with Laravel best practices for clean code but goes further by forbidding Eloquent from leaking into domain entities.
How Do You Structure Folders for Clean Architecture in Laravel?
Start with a src/ directory beside app/, or nest modules under app/Modules/Booking/ if you prefer PSR-4 under the default tree. Both work on Laravel 12 and 13. What matters is consistent boundaries, not the exact path string.
Recommended directory layout
app/
Modules/
Booking/
Domain/
Entities/Booking.php
ValueObjects/Money.php
Exceptions/SlotUnavailable.php
Application/
Ports/BookingRepository.php
Ports/PaymentGateway.php
UseCases/CreateBooking.php
DTOs/CreateBookingInput.php
Infrastructure/
Persistence/EloquentBookingRepository.php
Payment/KhaltiPaymentGateway.php
Presentation/
Http/Controllers/BookingController.php
Http/Requests/StoreBookingRequest.php
routes/
api.php
tests/
Unit/Booking/CreateBookingTest.php
Feature/Booking/BookingApiTest.php Register bindings in a dedicated service provider. Map each port interface to its adapter implementation. That keeps Laravel's container useful without letting it define your domain. See Laravel service providers explained for binding patterns that scale across modules.
Comparison: traditional MVC vs clean modules
| Aspect | Classic Laravel MVC | Clean Architecture module |
|---|---|---|
| Business rules location | Controller or Eloquent model | Domain entity + use case |
| Database coupling | Direct Eloquent calls everywhere | Repository adapter behind a port |
| Unit test speed | Needs database or heavy mocks | Plain PHP tests, no framework boot |
| Payment swap effort | Touch controllers, jobs, listeners | Replace one gateway adapter |
| Learning curve | Low for small apps | Higher upfront, lower long-term cost |
| Best fit | CRUD admin, marketing sites | Booking, billing, legal workflows |
For a modular monolith with Laravel, each bounded context gets the same four folders. Shared kernel types — money, email, UUID — live in a small Shared/Domain package both modules import.
What Does a Clean Architecture in Laravel Real Example Look Like in Code?
Imagine a notary-style document booking flow: a client picks a slot, pays a deposit, and staff receive a notification. Domain rules include slot capacity, deposit amount, and status transitions. None of that belongs in a controller.
Step 1 — Domain entity (framework-free)
<?php
namespace App\Modules\Booking\Domain\Entities;
use App\Modules\Booking\Domain\ValueObjects\Money;
use App\Modules\Booking\Domain\Exceptions\SlotUnavailable;
final class Booking
{
private function __construct(
private string $id,
private string $clientEmail,
private \DateTimeImmutable $slotAt,
private Money $deposit,
private string $status,
) {}
public static function request(
string $id,
string $clientEmail,
\DateTimeImmutable $slotAt,
Money $deposit,
int $remainingSlots
): self {
if ($remainingSlots < 1) {
throw SlotUnavailable::forDate($slotAt);
}
return new self($id, $clientEmail, $slotAt, $deposit, 'pending_payment');
}
public function markPaid(): void
{
if ($this->status !== 'pending_payment') {
throw new \DomainException('Booking is not awaiting payment.');
}
$this->status = 'confirmed';
}
public function status(): string
{
return $this->status;
}
} The entity knows nothing about Eloquent, HTTP, or Khalti. It throws domain exceptions your outer layers translate to HTTP responses. That pattern mirrors work on notary service portals where status rules must stay consistent across web forms and admin panels.
Step 2 — Application port and use case
<?php
namespace App\Modules\Booking\Application\Ports;
use App\Modules\Booking\Domain\Entities\Booking;
interface BookingRepository
{
public function countForSlot(\DateTimeImmutable $slotAt): int;
public function save(Booking $booking): void;
} <?php
namespace App\Modules\Booking\Application\UseCases;
use App\Modules\Booking\Application\DTOs\CreateBookingInput;
use App\Modules\Booking\Application\Ports\BookingRepository;
use App\Modules\Booking\Application\Ports\PaymentGateway;
use App\Modules\Booking\Domain\Entities\Booking;
use App\Modules\Booking\Domain\ValueObjects\Money;
final class CreateBooking
{
public function __construct(
private BookingRepository $bookings,
private PaymentGateway $payments,
) {}
public function handle(CreateBookingInput $input): string
{
$remaining = 3 - $this->bookings->countForSlot($input->slotAt);
$booking = Booking::request(
id: $input->id,
clientEmail: $input->email,
slotAt: $input->slotAt,
deposit: Money::npr(1500),
remainingSlots: $remaining,
);
$this->bookings->save($booking);
return $this->payments->initiateDeposit(
reference: $booking->status(),
amount: Money::npr(1500),
);
}
} The use case orchestrates. It does not format JSON or send mail. That separation makes CQRS in Laravel easier later if reads and writes diverge.
Step 3 — Infrastructure adapter (Eloquent)
<?php
namespace App\Modules\Booking\Infrastructure\Persistence;
use App\Models\BookingRecord;
use App\Modules\Booking\Application\Ports\BookingRepository;
use App\Modules\Booking\Domain\Entities\Booking;
final class EloquentBookingRepository implements BookingRepository
{
public function countForSlot(\DateTimeImmutable $slotAt): int
{
return BookingRecord::query()
->where('slot_at', $slotAt->format('Y-m-d H:i:s'))
->whereIn('status', ['pending_payment', 'confirmed'])
->count();
}
public function save(Booking $booking): void
{
BookingRecord::updateOrCreate(
['uuid' => $booking->id()],
['status' => $booking->status()]
);
}
} Eloquent stays in infrastructure. Your domain never imports Illuminate\Database\Eloquent\Model. If you later move reads to PostgreSQL 18, you change one adapter. That approach pairs well with guidance in the PostgreSQL for Laravel developers guide.
Step 4 — Thin controller
<?php
namespace App\Modules\Booking\Presentation\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Booking\Application\DTOs\CreateBookingInput;
use App\Modules\Booking\Application\UseCases\CreateBooking;
use App\Modules\Booking\Presentation\Http\Requests\StoreBookingRequest;
use Illuminate\Http\JsonResponse;
final class BookingController extends Controller
{
public function store(
StoreBookingRequest $request,
CreateBooking $createBooking
): JsonResponse {
$paymentUrl = $createBooking->handle(
CreateBookingInput::fromRequest($request)
);
return response()->json(['payment_url' => $paymentUrl], 201);
}
} The controller validates shape, maps to a DTO, and returns a response. Roughly fifteen lines. Compare that to a two-hundred-line controller that also sends SMS and calculates tax. Thin controllers are a signal your architecture is holding.
How Do You Wire Dependencies and Test Clean Architecture in Laravel?
Binding ports to adapters belongs in a module service provider. Laravel 13 resolves constructor type-hints automatically once interfaces are bound.
<?php
namespace App\Modules\Booking;
use App\Modules\Booking\Application\Ports\BookingRepository;
use App\Modules\Booking\Application\Ports\PaymentGateway;
use App\Modules\Booking\Infrastructure\Persistence\EloquentBookingRepository;
use App\Modules\Booking\Infrastructure\Payment\KhaltiPaymentGateway;
use Illuminate\Support\ServiceProvider;
final class BookingServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(BookingRepository::class, EloquentBookingRepository::class);
$this->app->bind(PaymentGateway::class, KhaltiPaymentGateway::class);
}
} Register the provider in bootstrap/providers.php on Laravel 11+. Unit tests hit the use case with in-memory fakes. No RefreshDatabase trait required for core rule coverage.
<?php
use App\Modules\Booking\Application\UseCases\CreateBooking;
use Tests\Support\FakeBookingRepository;
use Tests\Support\FakePaymentGateway;
it('rejects booking when slot is full', function () {
$repo = new FakeBookingRepository(remaining: 0);
$case = new CreateBooking($repo, new FakePaymentGateway());
expect(fn () => $case->handle($input))
->toThrow(SlotUnavailable::class);
}); Feature tests still boot Laravel and hit routes. Follow Laravel feature testing best practices for HTTP coverage. Use unit tests for every branch in domain entities. That split keeps CI fast on GitLab pipelines I run for production sites.
When debugging payload issues, a JSON formatter tool helps inspect API responses without cluttering production logs. Keep domain exceptions mapped in a single exception handler or middleware for consistent status codes across modules.
When Should You Adopt Clean Architecture in Laravel—and When Should You Skip It?
Not every project earns the folder overhead. Use this decision checklist before you refactor a working CRUD app.
- Adopt when multiple entry points — HTTP, CLI, queued jobs, webhooks — share the same rules.
- Adopt when you expect payment, SMS, or storage providers to change per client or region.
- Adopt when compliance or audit trails require explicit domain language in code reviews.
- Skip when the app is mostly content pages with occasional contact forms.
- Skip when the team is junior and delivery deadline is under four weeks with no follow-on phase.
- Hybrid when only one module is complex — wrap booking or billing cleanly and leave CMS code conventional.
On trek booking systems with supplier CRM logic, I keep itinerary pricing in domain services while admin CRUD stays in Filament resources. Pragmatic beats dogmatic. The Laravel repository pattern is a lighter step if full clean modules feel heavy today.
External references help anchor terminology. Martin's original layering essay defines dependency rules clearly. The Laravel 12 application structure documentation shows default folders you extend, not replace. PHP 8.3+ readonly properties and enums fit value objects well — see the PHP enumeration manual for typed status fields inside entities.
For API-only modules, pair this layout with Laravel API best practices and versioned routes. Event-heavy flows can publish domain events from use cases and handle side effects through event-driven architecture with Laravel events without pulling listeners into the domain ring.
Key Takeaways
- Put business rules in domain entities and use cases; keep Eloquent, HTTP, and gateways in outer adapter layers.
- Define repository and payment ports as interfaces; bind concrete adapters in a module service provider.
- Write fast unit tests against use cases with fakes; reserve feature tests for route and auth coverage.
- Start with one complex bounded context — booking, billing, or documents — before restructuring the entire app.
- Controllers should map requests to DTOs and responses; if yours exceed thirty lines, extract a use case.
- Clean Architecture in Laravel real example code pays off when adapters multiply, not on static brochure sites.
People Also Ask
Is Clean Architecture the same as hexagonal architecture in Laravel?
They share the same goal: isolate domain logic from infrastructure. Hexagonal architecture emphasises ports and adapters explicitly. Clean Architecture names concentric layers — domain, application, infrastructure, presentation. In Laravel you can implement both with the same folder layout and interface bindings.
Does Clean Architecture work with Laravel Eloquent?
Yes, but Eloquent models belong in the infrastructure layer as persistence records. Map them to domain entities inside repository adapters. Never call Booking::create() from a use case. Let the adapter translate between the active record model and your plain PHP entity.
How does Clean Architecture affect Laravel performance?
The extra indirection adds negligible overhead at runtime. Constructor injection and interface resolution cost microseconds per request. The real performance wins come from testability — you catch bad queries in unit tests before they hit production MySQL 9.7 under load.
Can you combine Clean Architecture with Livewire or Filament?
Livewire components and Filament resources are presentation adapters. They should call the same use cases as HTTP controllers. On admin-heavy client portals, that single entry point stopped duplicate validation rules between public forms and staff dashboards.
Ship Maintainable Laravel Systems
Clean Architecture in Laravel real example code is not ceremony for its own sake. It is insurance against the next payment provider, the next API version, and the next developer who inherits your repo. Start with one module, bind your ports, and prove the tests run without the database. If you want help structuring a booking, legal, or custom software module on Laravel 12 or 13, review the portfolio of production Laravel applications or contact us to discuss architecture before the codebase hardens in the wrong shape.
Frequently Asked Questions
0 Comments
Leave a comment
Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

