Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Clean Architecture in Laravel Real Example

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.

Laravel Clean Architecture LayersDomainEntities + Value ObjectsApplicationUse Cases + PortsInfrastructureEloquent + Mail + GatewaysPresentationControllers + Livewire + APIDependencies flow inward only
Clean Architecture in Laravel real example: four layers with the domain at the centre and framework code on the outside.

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.

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

AspectClassic Laravel MVCClean Architecture module
Business rules locationController or Eloquent modelDomain entity + use case
Database couplingDirect Eloquent calls everywhereRepository adapter behind a port
Unit test speedNeeds database or heavy mocksPlain PHP tests, no framework boot
Payment swap effortTouch controllers, jobs, listenersReplace one gateway adapter
Learning curveLow for small appsHigher upfront, lower long-term cost
Best fitCRUD admin, marketing sitesBooking, 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.

Booking Request FlowControllerHTTP in/outUse CaseCreateBookingDomainBooking entityAdaptersDB + PaymentSequence inside one request1. FormRequest validates input shape2. Controller builds DTO, calls use case3. Entity enforces slot and status rules4. Repository + gateway persist and charge5. Controller maps result to JSON redirect
Clean Architecture in Laravel real example request path: controller delegates to a use case, domain rules run in plain PHP, adapters talk to MySQL and payment APIs.

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.

Before vs After RefactorFat ControllerValidation mixed with rulesEloquent queries inlinePayment calls in HTTP layerHard to unit testDuplicate logic in jobsChange ripples everywhereClean ModuleDomain owns booking rulesPorts hide Eloquent + KhaltiFast pure PHP unit testsJobs call same use caseAPI + web share coreSwap adapters onlyRefactor
Clean Architecture in Laravel real example refactor outcome: business rules move from controllers into testable domain and application layers.

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.

  1. Adopt when multiple entry points — HTTP, CLI, queued jobs, webhooks — share the same rules.
  2. Adopt when you expect payment, SMS, or storage providers to change per client or region.
  3. Adopt when compliance or audit trails require explicit domain language in code reviews.
  4. Skip when the app is mostly content pages with occasional contact forms.
  5. Skip when the team is junior and delivery deadline is under four weeks with no follow-on phase.
  6. 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.

Adoption Decision TreeNew Laravel project?Simple CRUD siteUse standard MVCComplex workflowsPlan clean modulesMultiple adapters?API + jobs + webhooksYes: Full Clean ArchitectureDomain + use cases + portsStart hybrid moduleOne bounded context firstRe-evaluate after first production release
Decision guide for Clean Architecture in Laravel real example adoption: match structure to workflow complexity, not framework fashion.

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

Domain rules live in plain PHP at the centre; Laravel handles HTTP, Eloquent, and gateways as outer adapters. Dependencies always point inward.

The article uses a module under app/Modules/Booking/ with four rings: Domain for entities, value objects, and exceptions; Application for ports, use cases, and DTOs; Infrastructure for Eloquent repositories and payment adapters such as Khalti; Presentation for controllers, form requests, and routes. You can also place code in a src/ directory beside app/, but consistent boundaries matter more than the exact path. Each bounded context in a modular monolith repeats the same layout, and shared types like Money or email value objects sit in a small Shared/Domain area both modules import.

They share the goal of isolating domain logic from infrastructure. Hexagonal architecture stresses ports and adapters; Clean Architecture names concentric layers. Same Laravel folder layout works for both.

Yes. Eloquent models stay in the infrastructure layer as persistence records. Repository adapters map BookingRecord rows to plain PHP domain entities. Use cases never call Booking::create() directly; the adapter translates between the active record model and your entity on save and load.

Extra indirection adds negligible runtime cost. Constructor injection and interface resolution cost microseconds per request, not meaningful latency on typical booking flows.

The article walks through a notary-style document booking flow on Laravel 12 or 13 with PHP 8.3+. A client picks a slot, pays a Rs 1,500 deposit via Khalti, and staff get notified. The Booking entity enforces slot capacity and status transitions without knowing HTTP or Eloquent. CreateBooking counts remaining slots, calls Booking::request(), saves through BookingRepository, and initiates payment through PaymentGateway. EloquentBookingRepository queries BookingRecord; BookingController maps StoreBookingRequest to CreateBookingInput and returns a payment URL JSON response in roughly fifteen lines.

Adopt it when multiple entry points such as HTTP routes, CLI commands, queued jobs, and webhooks share the same business rules. It pays off when payment, SMS, or storage providers may change per client or region, or when compliance and audit trails need explicit domain language in code reviews. On legal-tech and booking portals, keeping cancellation policy and deposit rules in use cases saved weeks during payment-gateway swaps and Laravel upgrades because adapters changed while domain code stayed stable.

Skip it for mostly content-driven sites with occasional contact forms where controllers handling CRUD are enough. Also skip when the team is junior and the delivery deadline is under four weeks with no planned follow-on phase. The article warns that extra folders and interfaces are real overhead on small apps. A five-page brochure site does not need module boundaries; forcing them slows delivery without a future payoff when adapters are unlikely to multiply.

Bind each application port interface to its infrastructure adapter inside a module service provider. The BookingServiceProvider registers BookingRepository to EloquentBookingRepository and PaymentGateway to KhaltiPaymentGateway. Register that provider in bootstrap/providers.php on Laravel 11 and later. Once bindings exist, Laravel 13 resolves constructor type-hints on use cases such as CreateBooking automatically. This keeps the container useful without letting Illuminate classes define your domain contracts or leak framework types into inner rings.

Unit tests target use cases and domain entities with in-memory fakes such as FakeBookingRepository and FakePaymentGateway, so you cover slot-full rejection and status rules without RefreshDatabase or booting the framework. Feature tests still hit HTTP routes for auth, validation, and JSON shape. Test every branch inside domain entities at unit level; reserve slower integration coverage for controllers and middleware. That split keeps GitLab CI pipelines fast on production sites while still protecting the booking API contract end to end.

In classic Laravel MVC, business rules often live in controllers or Eloquent models with direct database calls everywhere, which makes payment swaps painful and unit tests slow because they need the database or heavy mocks. Clean Architecture puts rules in domain entities and use cases, hides persistence behind repository ports, and keeps controllers thin mappers. Learning curve is higher upfront but lower long-term when adapters multiply. MVC fits CRUD admin and marketing sites; clean modules fit booking, billing, and legal workflows with changing integrations.

Domain holds enterprise rules: the Booking entity, Money value object, SlotUnavailable exception, and methods like markPaid() that enforce status transitions. Nothing there imports Illuminate or third-party SDKs. Infrastructure implements outward concerns: EloquentBookingRepository queries BookingRecord, KhaltiPaymentGateway talks to the payment API, and mappers convert rows to entities. If you later move reads to PostgreSQL 18, you change one repository adapter while domain and use case code stays untouched because inner rings never depended on MySQL-specific APIs.

Controllers should validate request shape, map to a DTO such as CreateBookingInput, delegate to a use case, and format the HTTP response. The article’s BookingController is about fifteen lines returning a 201 JSON payload with a payment URL. If a controller exceeds thirty lines and still calculates deposits, checks slot capacity, or initiates payments, extract a use case. Thin controllers signal that architecture boundaries are holding and business rules are not leaking back into the presentation ring after a refactor.

Yes. Use a hybrid approach when only booking, billing, or documents is complex. Wrap that bounded context in Domain, Application, Infrastructure, and Presentation folders while leaving CMS or simple admin CRUD in conventional Laravel structure. On trek booking systems, itinerary pricing can live in domain services while Filament resources handle supplier admin tables. Pragmatic beats dogmatic. The repository pattern alone is a lighter first step if full clean modules feel heavy before you commit to four-layer folders across the entire application.

A use case such as CreateBooking orchestrates application workflow: it reads input from a DTO, calls domain factories like Booking::request(), persists through port interfaces, and triggers external actions like payment initiation. It does not format JSON, render Blade, send mail, or query Eloquent directly. Constructor injection supplies BookingRepository and PaymentGateway implementations bound in the service provider. That separation keeps one place responsible for the booking request sequence and makes CQRS-style read/write splits easier later if reporting queries diverge from write paths.

Share this article

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.

Quick Contact Options
Choose how you want to connect me: