Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Building Web Apps with Laravel  Module 17 – Architecture, multi-tenancy and packages

Structuring large Laravel applications

Split a Laravel 13 app into app/Domain folders of actions, data objects and events, and keep modules apart with Pest architecture tests.

  • Advanced
  • 10 minutes
  • Examples run with PHP 8.5.11, Composer 2.10.3, Laravel 13.34.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 10 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Split a Laravel app into app/Domain folders of actions, data objects and events
  • Design one-method action classes that controllers, jobs, commands and Livewire can call
  • Choose between events, direct calls and queued jobs for cross-module calls
  • Write Pest architecture tests that fail when one module uses another's models or actions

Before you start

What you’ll learn · Before you start

What you will learn

  • Split a Laravel app into app/Domain folders of actions, data objects and events
  • Design one-method action classes that controllers, jobs, commands and Livewire can call
  • Choose between events, direct calls and queued jobs for cross-module calls
  • Write Pest architecture tests that fail when one module uses another's models or actions

Before you start

On this page

In this lesson’s refactor of a Pathshala admissions endpoint, the fat version of the AdmissionController printed 44 lines in wc -l and the refactored version printed 30, with the same feature test passing against both. The 44-line original holds validation, normalisation, a seat check, an event dispatch and the response shaping in one method, which is more than one person can hold in their head once the Fees and Attendance teams start reaching for the same models.

Laravel 13’s slim skeleton stays kind as long as one or two people own the whole project. This lesson shows one answer when it is not enough: an app/Domain/<Area> folder of actions, data objects and events, a short table that chooses between a direct call, an event and a queued job for the calls that cross module lines, and a Pest architecture test that keeps modules out of one another.

Where domain code lives

The default app/ folder of a Laravel 13 project is small on purpose. Controllers go under app/Http/Controllers/, models under app/Models/, service providers under app/Providers/, and that is nearly all. A team of one or two can hold the whole picture by searching those folders. The directory structure documentation leaves it to you to extend app/: Laravel does not care how you nest it, as long as composer.json autoloads it under App\.

A domain-driven folder: A folder under app/Domain/<Area>/ that groups one business area's actions, data objects, events and listeners, so one search finds the whole module and no file in another module reaches into its code. is app/Domain/<Area>/, where <Area> is one of the words the office uses: Admissions, Fees, Attendance, Payroll. Each module owns the classes that belong to its own job and never reaches into another module’s models. The result is a modular monolith: One deployable application with one database and one request/response cycle, whose folders of code read as if they were separate modules. Each module owns its area and talks to the others only through events or explicit interfaces.: one deployable application, one database, one request/response cycle, but the folders of code read as if they were separate.

Interactive · step by step One module: four folders Walk through the Admissions folder as the refactor leaves it. Every sub-label names a file in examples/after/.
One module, four kinds of file, step 5 of 5. Showing Admissions/, Actions/, Data/, Events/, Listeners/. In focus: Listeners/.Admissions/Actions/EnrolStudentData/AdmissionRequestEvents/StudentEnrolledListeners/SendWelcomeMessage

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. The module's root. Admissions lives under app/Domain/, next to its sibling modules Fees and Attendance. Everything the office needs about enrolling a student sits inside this one folder.
  2. Actions do one job each. Actions/ holds one invokable class per use case. The refactor's EnrolStudent takes an AdmissionRequest, checks the seat and dispatches a domain event, in twenty-nine lines.
  3. Data objects name the shape. Data/ holds readonly classes. AdmissionRequest::fromValidated normalises the HTTP form once and gives the rest of the module camelCase names instead of snake_case keys.
  4. Events are the public shape. Events/ holds the domain events other modules may listen for. StudentEnrolled carries one AdmissionRequest and nothing else; the Fees and Attendance listeners never read Admissions' tables.
  5. Listeners live beside events. Listeners/ holds the module's handlers for its own events, such as SendWelcomeMessage; event discovery finds them once bootstrap/app.php names app/Domain/*/Listeners. Another module's listener for StudentEnrolled would live in that module's Listeners/, not here.

Nothing changes in composer.json: the default "App\\": "app/" PSR-4 rule covers App\Domain\Admissions\Actions\EnrolStudent at app/Domain/Admissions/Actions/EnrolStudent.php. No new provider is needed either, but one line of bootstrap/app.php is: event discovery scans only app/Listeners by default, so a module’s listeners are found once withEvents(discover: [__DIR__.'/../app/Domain/*/Listeners']) names every module’s folder (the * matches each module).

When does the split pay off? A heuristic, stated as opinion rather than a rule: when a folder outgrows what one person can hold in their head. Picture the admissions controller grown to 300 lines across two methods, with nobody able to say where the seat-limit code should go; an app/Domain/Admissions/ folder makes that question easy. Splitting a 100-line project is premature, and splitting a 10,000-line project late takes far longer than splitting it on time. The signal is the search: when a search for “fees” keeps dragging a reader into app/Http/Controllers/AdmissionController.php, the split has already waited too long.

Actions: one method a class

An action class: A plain class with one public method, often handle, that holds one business rule and knows nothing of HTTP, so controllers, commands and jobs can all call it. More in “Controllers and route model binding” is a plain class with one public method, usually __invoke, that does one piece of the business. Pathshala’s EnrolStudent takes an admission request, checks the seat count and dispatches a domain event. Its constructor is empty, so the container builds it from its type-hints, which lets any caller take it as a dependency: the HTTP controller, a queued job that retries a failed webhook, an Artisan command that runs a nightly backfill, or a Livewire component on an admin page. The office rule lives in one file, called the same way from everywhere.

The lesson’s refactor example runs the same PHPUnit feature test, through php artisan test, against the fat and the slim controller, so the user-visible behaviour never changes:

One controller refactored into an action, with one feature test passing against both Python · refactor_session.py
from session import run

# A before-and-after refactor of Pathshala's admissions endpoint. The before controller holds validation,
# normalisation, the seat check and the event dispatch in one method; the after controller delegates to an
# EnrolStudent action under app/Domain/Admissions/. The same feature test runs against both versions and stays
# green. The two overlay folders drop their files flat into the project root (overlay keeps paths), so the
# session cps each file to its real Laravel path and runs the test twice. Last, event:list shows that the module's
# listener is registered only after bootstrap/app.php points event discovery at app/Domain/*/Listeners.
run(
    """
cp web.php routes/web.php
cp AdmissionTest.php tests/Feature/AdmissionTest.php
cp AdmissionController.before.php app/Http/Controllers/AdmissionController.php
wc -l app/Http/Controllers/AdmissionController.php
php artisan test --filter=AdmissionTest
mkdir -p app/Domain/Admissions/Actions app/Domain/Admissions/Data app/Domain/Admissions/Events app/Domain/Admissions/Listeners
cp AdmissionController.after.php app/Http/Controllers/AdmissionController.php
cp EnrolStudent.php app/Domain/Admissions/Actions/EnrolStudent.php
cp AdmissionRequest.php app/Domain/Admissions/Data/AdmissionRequest.php
cp StudentEnrolled.php app/Domain/Admissions/Events/StudentEnrolled.php
cp SendWelcomeMessage.php app/Domain/Admissions/Listeners/SendWelcomeMessage.php
wc -l app/Http/Controllers/AdmissionController.php app/Domain/Admissions/Actions/EnrolStudent.php app/Domain/Admissions/Data/AdmissionRequest.php
php artisan test --filter=AdmissionTest
php artisan event:list --event=StudentEnrolled
cp app.php bootstrap/app.php
php artisan event:list --event=StudentEnrolled
""",
    app=True,
    overlay=("before", "after"),
)

Output

$ cp web.php routes/web.php
$ cp AdmissionTest.php tests/Feature/AdmissionTest.php
$ cp AdmissionController.before.php app/Http/Controllers/AdmissionController.php
$ wc -l app/Http/Controllers/AdmissionController.php
      44 app/Http/Controllers/AdmissionController.php
$ php artisan test --filter=AdmissionTest
   PASS  Tests\Feature\AdmissionTest
  ✓ post admissions returns 201 with the centre                          …s

  Tests:    1 passed (2 assertions)
  Duration: …s
$ mkdir -p app/Domain/Admissions/Actions app/Domain/Admissions/Data app/Domain/Admissions/Events app/Domain/Admissions/Listeners
$ cp AdmissionController.after.php app/Http/Controllers/AdmissionController.php
$ cp EnrolStudent.php app/Domain/Admissions/Actions/EnrolStudent.php
$ cp AdmissionRequest.php app/Domain/Admissions/Data/AdmissionRequest.php
$ cp StudentEnrolled.php app/Domain/Admissions/Events/StudentEnrolled.php
$ cp SendWelcomeMessage.php app/Domain/Admissions/Listeners/SendWelcomeMessage.php
$ wc -l app/Http/Controllers/AdmissionController.php app/Domain/Admissions/Actions/EnrolStudent.php app/Domain/Admissions/Data/AdmissionRequest.php
      30 app/Http/Controllers/AdmissionController.php
      29 app/Domain/Admissions/Actions/EnrolStudent.php
      28 app/Domain/Admissions/Data/AdmissionRequest.php
      87 total
$ php artisan test --filter=AdmissionTest
   PASS  Tests\Feature\AdmissionTest
  ✓ post admissions returns 201 with the centre                          …s

  Tests:    1 passed (2 assertions)
  Duration: …s
$ php artisan event:list --event=StudentEnrolled
   INFO  Your application doesn't have any events matching the given criteria.
$ cp app.php bootstrap/app.php
$ php artisan event:list --event=StudentEnrolled
  App\Domain\Admissions\Events\StudentEnrolled ...............................
  ⇂ App\Domain\Admissions\Listeners\SendWelcomeMessage@handle

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 refactor_session.py

The before controller (44 lines) carries validation, normalisation, the seat check, the event dispatch and the response shape in one store method. The after controller (30 lines) validates the HTTP shape and asks the EnrolStudent action to do the rest. The action owns the seat rule, the data object normalises the HTTP names into camel case, and the event file is the only shape another module may listen for.

The last three commands show the one line the move costs. With the skeleton’s bootstrap/app.php, php artisan event:list --event=StudentEnrolled finds no listener at all, because discovery looks only in app/Listeners; once the file names app/Domain/*/Listeners, the same command lists SendWelcomeMessage@handle for StudentEnrolled. A listener that nothing registered fails silently: the feature test above passed while SendWelcomeMessage was not registered, so only a test that checks the listener ran, or event:list, catches it.

The seven files the refactor moves or adds

Before: fat controller · before/AdmissionController.before.php

<?php

namespace App\Http\Controllers;

use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str;

// The admissions controller as a growing team tends to leave it: validation, normalisation, the seat check, the
// event dispatch and the response shaping, all in one method. Everything a reviewer must hold in their head to
// change one line of this feature lives in this one file.
class AdmissionController extends Controller
{
    public function store(Request $request): JsonResponse
    {
        $data = $request->validate([
            'student_name'   => ['required', 'string', 'min:2', 'max:120'],
            'guardian_phone' => ['required', 'regex:/^[6-9]\d{9}$/'],
            'course'         => ['required', 'string', 'max:60'],
            'centre'         => ['required', 'string', 'max:60'],
        ]);

        $studentName = (string) Str::of($data['student_name'])->squish()->title();
        $course      = (string) Str::of($data['course'])->squish();
        $centre      = (string) Str::of($data['centre'])->squish();

        $seatKey = sprintf('seats:%s:%s', Str::slug($centre), Str::slug($course));
        if ((int) Cache::get($seatKey, 0) >= 30) {
            return response()->json(['error' => 'course is full'], 409);
        }

        Cache::increment($seatKey);
        Event::dispatch('student.enrolled', [[
            'student_name'   => $studentName,
            'guardian_phone' => $data['guardian_phone'],
            'course'         => $course,
            'centre'         => $centre,
        ]]);

        return response()->json(['centre' => $centre], 201);
    }
}

After: slim controller · after/AdmissionController.after.php

<?php

namespace App\Http\Controllers;

use App\Domain\Admissions\Actions\EnrolStudent;
use App\Domain\Admissions\Data\AdmissionRequest;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

// The admissions controller after the refactor: validates the HTTP shape, hands the parsed request to one action
// and shapes the response. Everything the office cares about (seat limits, who else needs telling, how the id is
// built) now lives in the Admissions module, where a search for "fees" or "attendance" never has to tread.
class AdmissionController extends Controller
{
    public function store(Request $request, EnrolStudent $enrol): JsonResponse
    {
        $data = $request->validate([
            'student_name'   => ['required', 'string', 'min:2', 'max:120'],
            'guardian_phone' => ['required', 'regex:/^[6-9]\d{9}$/'],
            'course'         => ['required', 'string', 'max:60'],
            'centre'         => ['required', 'string', 'max:60'],
        ]);

        $outcome = $enrol(AdmissionRequest::fromValidated($data));

        return $outcome['ok']
            ? response()->json(['centre' => $outcome['centre']], 201)
            : response()->json(['error' => 'course is full'], 409);
    }
}

Action · after/EnrolStudent.php

<?php

namespace App\Domain\Admissions\Actions;

use App\Domain\Admissions\Data\AdmissionRequest;
use App\Domain\Admissions\Events\StudentEnrolled;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;

// One job per class: EnrolStudent takes a parsed admission and either takes a seat for it or reports back that
// the course is full. The controller, the queue worker that retries a failed webhook and the console seeder for
// a new centre all call the same __invoke method, so the office rule lives in exactly one place.
final class EnrolStudent
{
    private const SEATS = 30;

    public function __invoke(AdmissionRequest $request): array
    {
        $seatKey = sprintf('seats:%s:%s', Str::slug($request->centre), Str::slug($request->course));
        if ((int) Cache::get($seatKey, 0) >= self::SEATS) {
            return ['ok' => false, 'centre' => $request->centre];
        }

        Cache::increment($seatKey);
        StudentEnrolled::dispatch($request);

        return ['ok' => true, 'centre' => $request->centre];
    }
}

Data object · after/AdmissionRequest.php

<?php

namespace App\Domain\Admissions\Data;

use Illuminate\Support\Str;

// A readonly data object that stands between the HTTP shape (snake_case names, trailing spaces, lower-case
// titles) and the domain's own vocabulary. Nothing inside the Admissions module reads the request array
// directly: everything takes an AdmissionRequest and names its fields in camel case.
final readonly class AdmissionRequest
{
    public function __construct(
        public string $studentName,
        public string $guardianPhone,
        public string $course,
        public string $centre,
    ) {}

    public static function fromValidated(array $data): self
    {
        return new self(
            studentName:   (string) Str::of($data['student_name'])->squish()->title(),
            guardianPhone: $data['guardian_phone'],
            course:        (string) Str::of($data['course'])->squish(),
            centre:        (string) Str::of($data['centre'])->squish(),
        );
    }
}

Event · after/StudentEnrolled.php

<?php

namespace App\Domain\Admissions\Events;

use App\Domain\Admissions\Data\AdmissionRequest;
use Illuminate\Foundation\Events\Dispatchable;

// A domain event: the only shape the rest of the app sees when a student is enrolled. The Fees and Attendance
// modules listen for this class without knowing anything else about the Admissions module.
final class StudentEnrolled
{
    use Dispatchable;

    public function __construct(public AdmissionRequest $request)
    {
    }
}

Listener · after/SendWelcomeMessage.php

<?php

namespace App\Domain\Admissions\Listeners;

use App\Domain\Admissions\Events\StudentEnrolled;
use Illuminate\Support\Facades\Log;

// A listener kept beside the event it listens for. Event discovery registers it for the event its handle() method
// type-hints once bootstrap/app.php names the module folders (withEvents(discover: [.../app/Domain/*/Listeners]));
// by default Laravel scans only app/Listeners.
final class SendWelcomeMessage
{
    public function handle(StudentEnrolled $event): void
    {
        Log::info('welcome queued', [
            'student' => $event->request->studentName,
            'centre'  => $event->request->centre,
        ]);
    }
}

bootstrap/app.php · after/app.php

<?php

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;

// bootstrap/app.php of the Laravel 13 skeleton with one call added: withEvents() points event discovery at every
// module's Listeners folder. Without it Laravel scans only app/Listeners, so a listener kept in
// app/Domain/Admissions/Listeners is never registered and its event reaches nobody.
return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withEvents(discover: [
        __DIR__.'/../app/Domain/*/Listeners',
    ])
    ->withMiddleware(function (Middleware $middleware): void {
        //
    })
    ->withExceptions(function (Exceptions $exceptions): void {
        $exceptions->shouldRenderJsonWhen(
            fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
        );
    })->create();

A readonly data object is cheap insurance: nothing inside the module reads $data['student_name'] by key, and a rename of the HTTP field is one line in AdmissionRequest::fromValidated. The action signature __invoke(AdmissionRequest $request) reads like the sentence an office worker would say: “enrol this student”.

Checkpoint

Check: Where domain code lives · What an action class is for

  1. Question 1 of 2 Pathshala's app/Http/Controllers/AdmissionController.php has grown to 300 lines and nobody can say where new seat-limit code should go. What does the lesson suggest?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Make app/Domain/Admissions/ with Actions, Data, Events and Listeners and move the admissions code into it

    The lesson's signal is that a folder has outgrown what one person can hold in their head. The answer is a domain-driven folder under app/Domain/<Area>/, with Actions, Data, Events and Listeners.

  2. Question 2 of 2 Which callers can take Pathshala's EnrolStudent action without rewriting it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Any class the container builds, so a controller, a queued job, an Artisan command or a Livewire component

    An action class with an __invoke method and an empty (or injectable) constructor is callable by anything the container builds: controllers, jobs, commands and Livewire components all reach it the same way.

Modules talking: events, calls or jobs

Modules talk through one of three shapes: a direct call through an interface the other module publishes, a domain event with listeners in the modules that care, or a queued job that a worker runs later. Choosing among them is less a style question than a question about what the caller needs and what the receiver does. The lesson’s example trace is the one request that goes through the slim admissions controller:

Interactive · stage by stage One POST /admissions request, in seven stages Step through one admission for Asha Verma at Pathshala Pune. Each stage is a file the refactor wrote.
POST /admissions, one request, step 7 of 7. Showing POST /admissions (four fields, snake_case), AdmissionController (validate four rules), AdmissionRequest (camelCase, squished), EnrolStudent (seat 1 of 30), StudentEnrolled, SendWelcomeMessage, JSON 201 (201, centre). In focus: JSON 201.POST /admissionsfour fields, snake_caseAdmissionControllervalidate four rulesAdmissionRequestcamelCase, squishedEnrolStudentseat 1 of 30StudentEnrolledEvents/, dispatchedSendWelcomeMessageListeners/, log lineJSON 201201, centre201 JSON

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. The request arrives. Asha's form posts JSON to /admissions. Laravel's router finds the route in routes/web.php and gives the request to the controller's store method.
  2. The controller validates. AdmissionController::store runs $request->validate with four rules. On a failure Laravel returns 422 and nothing else below runs; on success the validated array is handed on.
  3. One readonly request object. AdmissionRequest::fromValidated squishes the names, title-cases the student name and gives the module camelCase properties. The HTTP shape stops here.
  4. The action takes a seat. EnrolStudent::__invoke reads the seat count under seats:<centre>:<course>. At thirty it returns ok false; otherwise it increments the count and dispatches StudentEnrolled.
  5. The event is fired. StudentEnrolled::dispatch($request) fires the domain event with the AdmissionRequest. Nothing else in Admissions runs synchronously; the module's work is done.
  6. A listener logs the welcome. SendWelcomeMessage::handle writes one Log::info line with the student's name and the centre. The Fees and Attendance modules add their own listeners later.
  7. The response goes back. Control returns to the controller. It sees ok true and returns JSON 201 with the centre. The whole chain, from HTTP to response, is six classes long.

The three shapes make different promises. A direct call is a method call in the same process: the caller gets the receiver’s return value or its exception. Between modules, the caller type-hints an interface the receiving module publishes, such as App\Domain\Admissions\Contracts\SeatCounter, whose method returns a number, not a model; one bind() call in a service provider tells the container which Admissions class to inject, so no other module imports its actions. An event is a dispatch to listeners that the caller never names; by default the listeners run synchronously, so the response still waits for them, but a listener that implements ShouldQueue moves to the queue. A job is a message handed to a worker process: the dispatch returns at once and the sender never sees the outcome.

Interactive · answer and compare Events, direct calls or jobs between modules? Answer two questions about the work being asked for. The table marks the ways that fit and says why the others do not.
Does the caller need the receiver's result in the same response?
Does the work talk to a slow outside service (SMS, email, payment gateway)?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Three ways to connect Pathshala's modules, against the facts the refactor example recorded.
Way of calling Coupling between the two sides The sender gets a return value When the receiver runs How a failure is reported
Direct call Program shape: The sender takes the other module's interface as a dependency and gets its return value. Mixed The sender names the receiver's interface; changing it breaks the sender, a new class behind it does not. Good A return value or an exception is handed back in the same request. Good Inside the same request; the response waits until the receiver returns. Good An exception thrown by the receiver reaches the sender, so the controller can shape a 4xx response.
Event + listener Program shape: The sender dispatches an event; one or more listeners run, synchronously by default. Good The sender dispatches a class; it never names the listeners, so a new listener costs the sender nothing. Poor No return value; a listener's result cannot reach the sender. Mixed Synchronous by default: listeners run before the response. ShouldQueue on a listener moves it to the queue. Mixed A sync listener's exception fails the request; a queued listener's exception goes to the worker log.
Queued job Program shape: The sender dispatches a job; a worker runs it later, with retries and backoff. Good The sender dispatches a job class; the sender does not need to know what runs after. Poor The dispatch returns at once; the sender never sees the job's outcome. Poor After the response, when a worker picks the job up: milliseconds to seconds, longer under load. Good The worker catches the exception, counts it against tries and records failed_jobs when the budget runs out.
How the answers decide
  • “Does the caller need the receiver's result in the same response?” Yes, the response depends on it and “Does the work talk to a slow outside service (SMS, email, payment gateway)?” No, it stays in this app: rules out Event + listener and Queued job. A fast call that shapes the response reads most plainly as a direct call: the controller gets the return value and the exception.
  • “Does the caller need the receiver's result in the same response?” Yes, the response depends on it and “Does the work talk to a slow outside service (SMS, email, payment gateway)?” Yes, over the network: rules out Event + listener and Queued job and adds a caveat to Direct call. The response needs the result, so a job cannot help; a direct call to a slow service lengthens every response. Measure p95 before shipping.
  • “Does the caller need the receiver's result in the same response?” No, it is a side effect and “Does the work talk to a slow outside service (SMS, email, payment gateway)?” No, it stays in this app: rules out Queued job and Direct call. An event lets other modules listen without the sender knowing their names, and the work is small enough to stay in the request.
  • “Does the caller need the receiver's result in the same response?” No, it is a side effect and “Does the work talk to a slow outside service (SMS, email, payment gateway)?” Yes, over the network: rules out Event + listener and Direct call. A job queues the slow work with retries and backoff; a sync event or a direct call would hold up every response.

The question “does the caller need the result in the same response?” picks the row. A controller that must return a 201 with the enrolled centre takes a direct call to EnrolStudent; a side effect such as sending the welcome message goes on an event listener: A class that reacts to one event class. Laravel registers it for the event that its handle or __invoke method is type-hinted with. More in “Events, listeners and model observers”; a slow outside call, such as a payment gateway, goes on a queued job with retries and backoff. A sync event that reaches a slow SMS gateway holds up every admission request, so measure before promising a listener will stay sync.

The pattern that hurts, in practice, is a listener that reaches into another module’s model. A listener registered by the Fees module for StudentEnrolled must not type-hint App\Domain\Admissions\Models\Enrolment; it should read the plain values off the event and build its own Fees-side data. The next section lets a test refuse that reach.

Boundaries enforced by Pest arch tests

An architecture test: A test that states a rule about the shape of the code, such as which classes may use a function, and checks every class against it without running the code. More in “Browser tests, static analysis and CI” is a Pest rule about every class in the project’s own namespaces, checked without running any of the code. Each rule names a boundary rule: An architecture rule that says what one module may or may not import. 'The Fees module must not use the Admissions module' is a boundary rule written as one Pest arch() expectation. as a short sentence: “every action class is final and has an __invoke method”, “Fees may not use Admissions models or actions”. Pest finds the files of each namespace through Composer’s autoload paths and reads their source, so no request, database or browser is involved.

The test file states three rules, and the session runs it twice:

tests/Arch/BoundariesTest.php: three rules about the code PHP · arch/tests/Arch/BoundariesTest.php
<?php

// Pest architecture tests are facts about every class in the project's own namespaces, checked without running
// any application code. Each arch() call names one rule: a short description, the classes it looks at and the
// shape it enforces.

arch('every action class is final and has an __invoke method')
    ->expect('App\\Domain\\Fees\\Actions')
    ->toBeFinal()
    ->toHaveMethod('__invoke');

// Admissions' events are its public face: a Fees listener may import StudentEnrolled. Its models and actions are
// private to Admissions, so the rule names those two namespaces rather than the whole module.
arch('Fees may not use Admissions models or actions')
    ->expect('App\\Domain\\Fees')
    ->not->toUse(['App\\Domain\\Admissions\\Models', 'App\\Domain\\Admissions\\Actions']);

arch('no debugging helpers are left behind in the domain code')
    ->expect(['dd', 'dump', 'var_dump', 'ray'])
    ->not->toBeUsed();
Pest Arch rules fail, then pass once the boundary is restored Python · arch_test_session.py
from session import run

# Pest architecture tests for the Pathshala project. The session installs Pest and its arch plugin offline from
# the course's repo-testing (lane 0147) with -q so the composer step prints nothing but its own line, then runs
# tests/Arch/BoundariesTest.php. The initial state has a boundary violation: the Fees IssueReceipt action imports
# Admissions' Enrolment model, which the rule refuses. The fix (good/IssueReceipt.php) takes plain values, and
# the Fees listener that calls the action shapes them from an event.
run(
    """
# Install Pest and its arch plugin offline from the course's repository (output suppressed with -q).
composer require --dev pestphp/pest -W -q
! vendor/bin/pest tests/Arch/BoundariesTest.php
cp good/IssueReceipt.php app/Domain/Fees/Actions/IssueReceipt.php
vendor/bin/pest tests/Arch/BoundariesTest.php
""",
    app=True,
    overlay=("arch",),
    repos=("repo-testing",),
)

Output

# Install Pest and its arch plugin offline from the course's repository (output suppressed with -q).
$ composer require --dev pestphp/pest -W -q
$ vendor/bin/pest tests/Arch/BoundariesTest.php
   FAIL  Tests\Arch\BoundariesTest
  ✓ every action class is final and has an __invoke method               …s
  ⨯ Fees may not use Admissions models or actions                        …s
  ✓ no debugging helpers are left behind in the domain code              …s
  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Arch\BoundariesTest > Fees may not use Admissions models or…
  Expecting 'App\Domain\Fees' not to use 'App\Domain\Admissions\Models'.


  Tests:    1 failed, 2 passed (6 assertions)
  Duration: …s
[exit status 1]
$ cp good/IssueReceipt.php app/Domain/Fees/Actions/IssueReceipt.php
$ vendor/bin/pest tests/Arch/BoundariesTest.php
   PASS  Tests\Arch\BoundariesTest
  ✓ every action class is final and has an __invoke method               …s
  ✓ Fees may not use Admissions models or actions                        …s
  ✓ no debugging helpers are left behind in the domain code              …s

  Tests:    3 passed (7 assertions)
  Duration: …s

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 arch_test_session.py

In the first run, the Fees module’s IssueReceipt action takes an Admissions\Models\Enrolment directly, so the second rule fails with Expecting 'App\Domain\Fees' not to use 'App\Domain\Admissions\Models'. and the run ends with exit status 1, which is what turns a CI job red. The message names the two namespaces, not the file, so the next step is a search for use App\Domain\Admissions\Models under app/Domain/Fees/. The fixed IssueReceipt takes plain values ($enrolmentId, $centre, $course, $amountPaise) and the second run passes all three rules.

The rule names Admissions’ models and actions rather than the whole module on purpose. A Fees listener for StudentEnrolled has to import that event class, and Admissions’ events and published interfaces are the classes other modules are meant to use; a rule against all of App\Domain\Admissions would fail on the very design the previous section recommends.

The Pest documentation calls each arch() call a test: its body is a fluent chain of expectations rather than assertions written by hand. One more pattern worth refusing in the same file is a generic repository: A class whose methods forward to an Eloquent model (findById, findAll, save, delete). In Laravel this layer duplicates the query builder and rarely enables a storage swap; scopes or query objects usually serve better. that only wraps Eloquent: a UserRepository whose methods call User::query()->where(...) duplicates the query builder and adds a layer whose only trick is to name the parameter. Prefer a scope on the model or a plain query object; keep a repository for storage you may really swap, such as a cache in front of a slow HTTP source, where the interface pays for itself.

The rule the whole lesson rests on

Decide what each module owns. Keep its actions, data, events and listeners inside one folder, let other modules use only its events and the interfaces it publishes, and let an architecture test refuse the first file that reaches past them.

Checkpoint

Check: Events, calls or queued jobs · Pest architecture rules

  1. Question 1 of 2 The admissions controller must return a 201 to the parent and also send a welcome message by SMS, which takes 400 to 900 ms to the gateway. What does the Tradeoffs table pick?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A direct call to EnrolStudent for the seat and the 201, a queued job for the SMS

    The result-in-response column picks the direct call for EnrolStudent (needs the value and the exception). The slow outside call to the SMS gateway fits a job (fire and forget, retries, backoff), not a sync listener.

  2. Question 2 of 2 In the arch session, which line of IssueReceipt made the rule 'Fees may not use Admissions models or actions' fail?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: use App\Domain\Admissions\Models\Enrolment;

    Pest Arch's toUse rule reads the project's own autoloaded classes and fails when a class in App\Domain\Fees uses a class from a named namespace, here Admissions' Models. The import at the top of the file is the match.

Practise

Build the receipt area for Pathshala in your own project, with the same shape as the Admissions module in examples/after/:

  1. Make an app/Domain/Receipts/ folder with Actions/, Data/ and Listeners/.
  2. Write a MintReceipt action with a single __invoke that takes $enrolmentId, $centre, $course, $amountPaise and returns a readonly Receipt.
  3. Add a listener in app/Domain/Receipts/Listeners/ that handles App\Domain\Admissions\Events\StudentEnrolled, calls MintReceipt and writes the Receipt somewhere Receipts owns.
  4. Copy the tests/Arch/BoundariesTest.php of the arch session and add a rule that App\Domain\Receipts may not use App\Domain\Admissions\Models.

The self-check covers all four sections:

Practice that adapts to you

Practise this lesson

8 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 8 What does the lesson say an app/Domain/<Area>/ folder is for?

    Choose one answer.

    Show the answer: Question 1 of 8

    Answer: Grouping the actions, data, events and listeners of one area, so the module's code is one search away

    The lesson calls app/Domain/<Area>/ a domain-driven folder: one folder per area of the business (Admissions, Fees, Attendance) with its own actions, data objects, events and listeners.

  2. Question 2 of 8 When would the lesson say the split is premature?

    Choose one answer.

    Show the answer: Question 2 of 8

    Answer: In a 100-line project with one or two owners, where one person still holds the whole file in their head

    The split pays off when one folder outgrows what one person can hold in their head. A small, one or two-person codebase stays kind under the default skeleton.

  3. Question 3 of 8 What is the shape of an action class the lesson shows?

    Choose one answer.

    Show the answer: Question 3 of 8

    Answer: One public method (usually __invoke), with an empty or injectable constructor

    An action is a plain class, often marked final, with one public method (usually __invoke) and a constructor the container can build. Dependencies come in through the constructor.

  4. Question 4 of 8 Pathshala's EnrolStudent action grows to also issue a refund, cancel an enrolment and resend the welcome message, all as public methods on the same class. What does the lesson suggest?

    Choose one answer.

    Show the answer: Question 4 of 8

    Answer: Split each use case into its own invokable action class, under app/Domain/Admissions/Actions/

    One job per class is the lesson's rule for actions. Several methods on one class is the shape of a service; the lesson prefers one invokable action per use case.

  5. Question 5 of 8 Which cross-module shape does the lesson pick when the caller needs the receiver's result in the same response and the work is fast?

    Choose one answer.

    Show the answer: Question 5 of 8

    Answer: A direct call through an interface the receiving module publishes

    A direct call hands a value or an exception back in the same request. Pick it when the caller needs the result; an event never gives a return, and a job runs after the response.

  6. Question 6 of 8 A new synchronous listener on Pathshala's StudentEnrolled event calls an SMS gateway that takes 400 to 900 ms. Which claim is right about what happens?

    Choose one answer.

    Show the answer: Question 6 of 8

    Answer: Every admission request now waits 400 to 900 ms longer, because sync listeners run before the response

    Listeners run synchronously by default, so the response waits for them. A slow outside call in a sync listener lengthens every request; letting the listener implement ShouldQueue moves it to the queue.

  7. Question 7 of 8 An arch rule reads arch('Fees must not use Admissions')->expect('App\Domain\Fees')->not->toUse('App\Domain\Admissions'). Which event makes it fail?

    Choose one answer.

    Show the answer: Question 7 of 8

    Answer: Any class in App\Domain\Fees imports or type-hints a class in App\Domain\Admissions

    Pest's toUse rule checks that no file in the expected namespace imports or references any class from the named namespace. The arch session's bad IssueReceipt failed on exactly that import.

  8. Question 8 of 8 Why does the lesson prefer an architecture test over a code-review guideline for 'Fees may not use Admissions models'?

    Choose one answer.

    Show the answer: Question 8 of 8

    Answer: The test runs on every commit and fails before the pull request is merged, so a slip is caught before it ships

    Arch tests state the rules as facts about the code, so a Fees file that reaches into Admissions fails on the next test run. A written guideline relies on every reader remembering the rule.

Interview questions

A couple of warm-ups to answer in your own words, then two more with the deeper answers in the module’s bank.

Warm-up (fresher to 2–5 years): when do you split code into app/Domain/<Area>?

Not from the day the project is new. Laravel 13’s slim skeleton is kind as long as one or two people own the whole app. The signal is a controller or a model folder past the size one person can hold in their head: hundreds of lines, three concerns in one method, a search for “fees” that keeps dragging a reader into the admissions controller. Once four developers edit the same file in a week, the split pays for itself. Start by cutting one noisy folder at a time into app/Domain/<Area>/ with its own Actions, Data and Events; a big-bang split of a running codebase is painful and rarely finishes. Keep an architecture test ready for the first module, so the second module cannot reach into the first a week after the move.

Warm-up (fresher to 2–5 years): when is an event a better fit than a direct call?

When the caller does not need the receiver’s return value in the same response and the receivers may grow in number without the caller knowing their names. The admissions controller that must return a 201 to the parent should not call the Fees receipt code, the Attendance seat code and the welcome SMS code inline: it dispatches StudentEnrolled, and each module adds a listener in its own folder. A direct call fits when the caller needs the value or the exception back right away; a queued job fits when the work is slow enough to need retries. A synchronous event that calls an outside service holds up every request, so a listener that reaches an SMS gateway or a payment provider implements ShouldQueue.

Two more questions go further, into one costly pattern and one easy slip; the longer answers live in the module’s bank:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A teammate has written a UserRepository, a PaymentRepository and an EnrolmentRepository, each with findById, findAll, save and delete that forward to Eloquent. The methods are thin and every controller takes a repository in its constructor. Decide whether to keep the layer, and defend each half of your answer.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    Months after the Admissions module shipped, a search finds its Enrolment model type-hinted in a Fees listener and in an Attendance job. The architecture test covers Fees only. How do you find every such slip, make a test refuse them from now on, and fix the code without stopping the week's work?

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. What is a domain-driven folder in a Laravel app, and does composer.json need a change for it?

    app/Domain/ with one folder per module named after the office's words, such as Admissions or Fees, each owning its actions, data, events and listeners. The default PSR-4 rule already autoloads it under App\; nothing in composer.json changes.

  2. What is a modular monolith?

    One deployable application with one database and one request cycle, whose code folders read as if they were separate modules. Each module owns its classes and never reaches into another module's models.

  3. Why did event:list show no listener for StudentEnrolled after the move into app/Domain?

    Event discovery scans only app/Listeners by default. Name the modules' folders in bootstrap/app.php with withEvents(discover: [__DIR__.'/../app/Domain/*/Listeners']); a listener nothing registered fails silently, so check it with event:list or a test.

  4. What is an action class, and who can call it?

    A plain class with one public method, usually __invoke, that does one piece of the business, such as EnrolStudent. The container builds it from its type-hints, so a controller, a queued job, an Artisan command or a Livewire component all call the same rule.

  5. How do you choose between a direct call, an event and a queued job between modules?

    Ask whether the caller needs the result in the same response. If yes, a direct call through an interface the other module publishes; a side effect such as a welcome message goes to an event listener; a slow outside call, such as a payment gateway, goes to a queued job with retries.

  6. What should a Fees listener for StudentEnrolled never type-hint?

    Another module's model, such as the Admissions Enrolment. It reads the plain values off the event and builds its own Fees-side data, because Admissions' events and published interfaces are the only classes other modules may use.

  7. How does a Pest architecture test keep modules apart?

    An arch() rule such as Fees may not use Admissions models or actions reads the source of every class through Composer's autoload paths, without running code. The first file that reaches past the boundary fails the run with exit status 1, turning CI red.

  8. Why avoid a generic repository that only wraps Eloquent?

    A UserRepository whose methods call User::query()->where(...) duplicates the query builder and adds a layer that only renames the parameters. Prefer a model scope or a query object, and keep a repository for storage you may really swap.

Key takeaways

  • Split into app/Domain/<Area> folders when a folder outgrows what one person can hold in their head, not before.
  • An action class is a plain class with one __invoke method, called from controllers, jobs, commands and Livewire with the same arguments.
  • A readonly data object stops HTTP shapes from leaking into the module, so a renamed form field is one line of change.
  • Modules talk through events or the interfaces a module publishes; a listener never type-hints another module’s model, and an architecture test refuses it when one does.
  • A generic repository that only wraps Eloquent duplicates the query builder; prefer scopes, query objects or model methods unless you really will swap storage.

Terms in this lesson

boundary rule
An architecture rule that says what one module may or may not import. 'The Fees module must not use the Admissions module' is a boundary rule written as one Pest arch() expectation.
domain-driven folder
A folder under app/Domain/<Area>/ that groups one business area's actions, data objects, events and listeners, so one search finds the whole module and no file in another module reaches into its code.
generic repository
A class whose methods forward to an Eloquent model (findById, findAll, save, delete). In Laravel this layer duplicates the query builder and rarely enables a storage swap; scopes or query objects usually serve better.
modular monolith
One deployable application with one database and one request/response cycle, whose folders of code read as if they were separate modules. Each module owns its area and talks to the others only through events or explicit interfaces.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.