{"id":56,"date":"2026-10-20T22:21:11","date_gmt":"2026-10-20T17:21:11","guid":{"rendered":"https:\/\/blog.shaz3e.com\/?p=56"},"modified":"2026-10-23T08:58:58","modified_gmt":"2026-10-23T03:58:58","slug":"laravel-architecture-best-practices","status":"publish","type":"post","link":"https:\/\/www.shaz3e.com\/blog\/laravel-architecture-best-practices\/","title":{"rendered":"Laravel Architecture for Large Applications: The Complete Guide"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Laravel architecture best practices for a large application come down to three decisions. Where does code live? Where do business rules run? How do you prove the boundaries still hold six months and five developers later? Get those right and a Laravel codebase can grow to hundreds of models without turning every change into an archaeology project. Get them wrong and the framework&#8217;s convenience works against you, because anything can call anything.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I&#8217;m Shahrukh Akhtar (Shaz3e), CTO &amp; Co-Founder of <a href=\"https:\/\/diligentcreators.com\" target=\"_blank\" rel=\"noopener\">Diligent Creators Pvt. Ltd.<\/a>. Our products run on Laravel APIs with React front ends, and these are the rules I apply when a codebase outgrows the default skeleton. The guide compares three folder layouts, weighs services against actions and repositories, and sets out a testing strategy, with a small billing module and tests written for this post. It&#8217;s for teams anywhere, from startups in the USA and agencies in the UAE to product teams in Pakistan and India.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>In this guide<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n\n<li><a href=\"#laravel-architecture-best-practices\">Laravel architecture best practices at a glance<\/a><\/li>\n\n\n<li><a href=\"#folder-structure\">Laravel large application structure: three folder layouts<\/a><\/li>\n\n\n<li><a href=\"#service-layer\">Services, actions and repositories: the trade-offs<\/a><\/li>\n\n\n<li><a href=\"#worked-example\">A worked example: issuing an invoice<\/a><\/li>\n\n\n<li><a href=\"#domain-driven-design\">How much domain-driven design Laravel needs<\/a><\/li>\n\n\n<li><a href=\"#testing-strategy\">A testing strategy that matches the architecture<\/a><\/li>\n\n\n<li><a href=\"#tenancy-permissions\">Where tenancy and permissions fit<\/a><\/li>\n\n\n<li><a href=\"#team-decision\">Architecture is a team decision<\/a><\/li>\n\n\n<li><a href=\"#series\">More in this series<\/a><\/li>\n\n\n<li><a href=\"#faq\">FAQ<\/a><\/li>\n\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"laravel-architecture-best-practices\">Laravel architecture best practices at a glance<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">These are the defaults I start from, each explained with code further down.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Practice<\/th><th>Why<\/th><th>How you check it<\/th><\/tr><\/thead><tbody><tr><td>Keep controllers thin<\/td><td>HTTP concerns (auth, validation, response shape) change for different reasons than business rules<\/td><td>A controller method reads like a sentence: validate, call one action, return a resource<\/td><\/tr><tr><td>One class per business operation<\/td><td>&#8220;Issue invoice&#8221; and &#8220;refund invoice&#8221; get tested and changed separately<\/td><td>Each action has one public <code>handle<\/code> method<\/td><\/tr><tr><td>Group code by business area once you pass a handful of areas<\/td><td>A developer working on billing opens one folder, not seven<\/td><td>New billing code lands under <code>Billing\/<\/code><\/td><\/tr><tr><td>Use Eloquent directly inside the domain<\/td><td>A repository over Eloquent repeats Eloquent&#8217;s API with less power<\/td><td>Queries live in actions or named scopes on the model<\/td><\/tr><tr><td>Put contracts only at real boundaries<\/td><td>Tax rules, payment gateways and outside APIs change or get faked in tests<\/td><td>Interfaces exist where a second implementation (or a fake) is real<\/td><\/tr><tr><td>Enforce boundaries with tests<\/td><td>Rules written only in a wiki get broken quietly<\/td><td>Arch tests fail the build when the domain imports HTTP code<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"folder-structure\">Laravel large application structure: three folder layouts<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Laravel&#8217;s own <a href=\"https:\/\/laravel.com\/docs\/13.x\/structure\" target=\"_blank\" rel=\"noopener\">directory structure documentation<\/a> says the framework imposes almost no restrictions on where a class lives, as long as Composer can autoload it. In practice there are three layouts worth choosing between.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"default-structure\">1. The default layout, organized by type<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Everything is grouped by what it is: controllers with controllers, models with models. Add an <code>app\/Actions<\/code> folder and this layout handles a mid-sized product fine.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>app\/\n  Actions\/IssueInvoice.php\n  Events\/InvoiceIssued.php\n  Http\/Controllers\/InvoiceController.php\n  Http\/Requests\/StoreInvoiceRequest.php\n  Models\/Invoice.php\n  Models\/InvoiceLine.php<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Every Laravel developer knows where to look, and docs and packages assume it. It starts to hurt when <code>app\/Models<\/code> holds dozens of files from unrelated business areas.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"modules-structure\">2. Modules, each a mini application<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Each module gets its own routes, migrations, service provider, tests and source folder, and is registered like a package. Packages such as nwidart\/laravel-modules generate this layout, or you can map a <code>Modules\\<\/code> namespace in <code>composer.json<\/code> yourself.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>modules\/\n  Billing\/\n    database\/migrations\/\n    routes\/api.php\n    src\/\n      Actions\/IssueInvoice.php\n      Http\/Controllers\/InvoiceController.php\n      Models\/Invoice.php\n      BillingServiceProvider.php\n    tests\/\n  Catalog\/\n    ...<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Modules give the strongest separation short of separate services. They cost setup, and every new feature raises the question of which module owns it, while users and notifications drift into a <code>Core<\/code> module everyone depends on. They pay off when separate teams own separate areas, or when a module may be switched on per customer.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"domains-structure\">3. Domains inside app\/, organized by business area<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Business code moves into <code>app\/Domains\/{Area}<\/code>, while the HTTP layer stays where Laravel expects it. This is the layout the example in this post uses.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>app\/\n  Domains\/\n    Billing\/\n      Actions\/IssueInvoice.php\n      Contracts\/TaxCalculator.php\n      Data\/InvoiceLineData.php\n      Events\/InvoiceIssued.php\n      Models\/Invoice.php\n      Models\/InvoiceLine.php\n      Tax\/FlatRateTax.php\n      BillingServiceProvider.php\n  Http\/\n    Controllers\/InvoiceController.php\n    Requests\/StoreInvoiceRequest.php\n    Resources\/InvoiceResource.php<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">No Composer changes are needed, because <code>App\\<\/code> already maps to <code>app\/<\/code>. Routes, migrations and config stay in their usual places, so Artisan commands, IDE plugins and new hires keep working without a lesson.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"choosing-structure\">Which layout to choose<\/h3>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Layout<\/th><th>Fits<\/th><th>Main cost<\/th><th>Move on when<\/th><\/tr><\/thead><tbody><tr><td>Default (by type)<\/td><td>One team, up to a handful of business areas<\/td><td>Files from unrelated areas sit side by side<\/td><td>Folders like <code>Models<\/code> become hard to scan<\/td><\/tr><tr><td>Domains in <code>app\/<\/code><\/td><td>One or two teams, many business areas, one deployable app<\/td><td>Discipline: boundaries are folders until tests enforce them<\/td><td>Teams need to release areas on their own schedule<\/td><\/tr><tr><td>Modules<\/td><td>Several teams, per-customer features, areas that may be extracted later<\/td><td>Setup, a shared core and constant ownership questions<\/td><td>A module needs its own deploy or database<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">My default for a new product is the default layout plus <code>app\/Actions<\/code>, then a move to domains when the second or third business area shows up. Moving files between namespaces is a mechanical refactor that an IDE handles. Undoing a premature module system is far more work.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"service-layer\">Services, actions and repositories: the trade-offs<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The Laravel service layer question comes up in every growing codebase: where does the logic between the controller and the database go? The three common answers overlap, so it helps to define them.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Pattern<\/th><th>What it is<\/th><th>Use it when<\/th><th>Skip it when<\/th><\/tr><\/thead><tbody><tr><td>Action<\/td><td>One class, one operation, one public method (<code>IssueInvoice::handle<\/code>)<\/td><td>An operation is called from more than one place: a controller, a job, a console command, a test<\/td><td>The &#8220;operation&#8221; is a single Eloquent call with no rules around it<\/td><\/tr><tr><td>Service<\/td><td>A class grouping related operations or wrapping an outside system (<code>StripeBilling<\/code>, <code>SmsGateway<\/code>)<\/td><td>You talk to an external API, or several operations share state and setup<\/td><td>It&#8217;s becoming a 2,000-line <code>InvoiceService<\/code> that every feature edits<\/td><\/tr><tr><td>Repository<\/td><td>An interface over data access (<code>InvoiceRepository::findOverdue<\/code>)<\/td><td>Data comes from somewhere other than your own database, or you truly have two storage backends<\/td><td>It wraps Eloquent one-to-one, which hides eager loading, scopes and query building behind a thinner API<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"actions\">Actions for business operations<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">I reach for actions first. The file name is the operation, they&#8217;re easy to test, and a pull request that adds a business rule usually touches one action. Constructor injection keeps their dependencies visible.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"services\">Services at the edges<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Services earn their place at boundaries such as a payment provider, an SMS gateway or a tax engine. Put an interface in front, bind the real implementation in a service provider, and tests can swap in a fake. When a catch-all <code>InvoiceService<\/code> gets edited by two developers every sprint, split it into actions.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"repositories\">Repositories, mostly not<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Eloquent is already a data mapper with a query builder, so a repository that forwards <code>find<\/code>, <code>create<\/code> and <code>where<\/code> adds a layer without adding a capability. The usual argument is &#8220;we might change the database,&#8221; but Laravel&#8217;s database layer already handles the move between MySQL and PostgreSQL. For reusable queries, a named scope on the model reads better and composes with everything else. A repository makes sense when the data really lives elsewhere, such as an outside API you read as if it were a table.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"worked-example\">A worked example: issuing an invoice<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s a small billing domain written for this post. It issues an invoice with lines, adds tax through a swappable calculator, numbers invoices in sequence and announces the result with an event. The code targets Laravel 13 (which requires PHP 8.3 or newer). It was tested on Laravel 13.38, PHP 8.4 and Pest 5.3 against SQLite in memory, and all 13 of its tests pass. The column types are standard for MySQL 8 and PostgreSQL, but this example hasn&#8217;t been run against them yet.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"value-object\">A value object for invoice lines<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Money is stored as an integer in the currency&#8217;s minor units, so a team billing in USD, AED, PKR or INR never meets a floating-point rounding error. The constructor refuses impossible lines, which means no action has to check them again.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;?php\n\n\/\/ app\/Domains\/Billing\/Data\/InvoiceLineData.php\n\nnamespace App\\Domains\\Billing\\Data;\n\nuse InvalidArgumentException;\n\nfinal readonly class InvoiceLineData\n{\n    public function __construct(\n        public string $description,\n        public int $quantity,\n        public int $unitPriceMinor,\n    ) {\n        if ($quantity &lt; 1 || $unitPriceMinor &lt; 0) {\n            throw new InvalidArgumentException('Quantity must be at least 1 and price cannot be negative.');\n        }\n    }\n\n    public function totalMinor(): int\n    {\n        return $this-&gt;quantity * $this-&gt;unitPriceMinor;\n    }\n}<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"action\">The action<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The action owns the business rule and nothing else. It takes typed input, wraps the writes in a transaction and locks while it reads the last invoice number, so two concurrent requests can&#8217;t take the same one. The unique index on <code>number<\/code> is the backstop if something bypasses the action.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;?php\n\n\/\/ app\/Domains\/Billing\/Actions\/IssueInvoice.php\n\nnamespace App\\Domains\\Billing\\Actions;\n\nuse App\\Domains\\Billing\\Contracts\\TaxCalculator;\nuse App\\Domains\\Billing\\Data\\InvoiceLineData;\nuse App\\Domains\\Billing\\Events\\InvoiceIssued;\nuse App\\Domains\\Billing\\Models\\Invoice;\nuse Illuminate\\Support\\Facades\\DB;\nuse InvalidArgumentException;\n\nfinal class IssueInvoice\n{\n    public function __construct(private readonly TaxCalculator $tax) {}\n\n    \/** @param list&lt;InvoiceLineData&gt; $lines *\/\n    public function handle(string $customerName, string $currency, array $lines): Invoice\n    {\n        if ($lines === []) {\n            throw new InvalidArgumentException('An invoice needs at least one line.');\n        }\n\n        $subtotal = array_sum(array_map(fn (InvoiceLineData $line) =&gt; $line-&gt;totalMinor(), $lines));\n        $tax = $this-&gt;tax-&gt;taxFor($subtotal, $currency);\n\n        $invoice = DB::transaction(function () use ($customerName, $currency, $lines, $subtotal, $tax) {\n            \/\/ Lock while reading the last number so two requests can't take the same one.\n            \/\/ The unique index on invoices.number is the backstop.\n            $next = (int) Invoice::query()-&gt;lockForUpdate()-&gt;max('number') + 1;\n\n            $invoice = Invoice::create([\n                'number' =&gt; $next,\n                'customer_name' =&gt; $customerName,\n                'currency' =&gt; $currency,\n                'subtotal_minor' =&gt; $subtotal,\n                'tax_minor' =&gt; $tax,\n                'total_minor' =&gt; $subtotal + $tax,\n                'issued_at' =&gt; now(),\n            ]);\n\n            $invoice-&gt;lines()-&gt;createMany(array_map(fn (InvoiceLineData $line) =&gt; [\n                'description' =&gt; $line-&gt;description,\n                'quantity' =&gt; $line-&gt;quantity,\n                'unit_price_minor' =&gt; $line-&gt;unitPriceMinor,\n                'total_minor' =&gt; $line-&gt;totalMinor(),\n            ], $lines));\n\n            InvoiceIssued::dispatch($invoice);\n\n            return $invoice;\n        });\n\n        return $invoice-&gt;load('lines');\n    }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>InvoiceIssued<\/code> implements Laravel&#8217;s <code>ShouldDispatchAfterCommit<\/code> interface, so listeners for email, PDFs or webhooks only run after the transaction commits. Without it, a listener could send an email for an invoice that was rolled back a millisecond later.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"boundary-contract\">A contract at the boundary<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Tax rules differ by country and change over time, so the action depends on an interface and the rate comes from config. A country-specific calculator would implement the same interface and get bound instead.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>interface TaxCalculator\n{\n    \/** Returns the tax for a subtotal, both in the currency's minor units. *\/\n    public function taxFor(int $subtotalMinor, string $currency): int;\n}<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code>public function register(): void\n{\n    $this-&gt;app-&gt;bind(\n        TaxCalculator::class,\n        fn () =&gt; new FlatRateTax((int) config('billing.tax_basis_points', 0)),\n    );\n}<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"http-layer\">A thin HTTP layer<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The Form Request validates input and turns it into value objects. The controller calls the action and returns an API resource. Neither knows how an invoice is numbered or taxed.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>public function rules(): array\n{\n    return [\n        'customer_name' =&gt; ['required', 'string', 'max:160'],\n        'currency' =&gt; ['required', 'string', 'size:3', 'uppercase'],\n        'lines' =&gt; ['required', 'array', 'min:1', 'max:100'],\n        'lines.*.description' =&gt; ['required', 'string', 'max:255'],\n        'lines.*.quantity' =&gt; ['required', 'integer', 'min:1'],\n        'lines.*.unit_price_minor' =&gt; ['required', 'integer', 'min:0'],\n    ];\n}<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code>class InvoiceController extends Controller\n{\n    public function store(StoreInvoiceRequest $request, IssueInvoice $issueInvoice): JsonResponse\n    {\n        $invoice = $issueInvoice-&gt;handle(\n            $request-&gt;validated('customer_name'),\n            $request-&gt;validated('currency'),\n            $request-&gt;lines(),\n        );\n\n        return InvoiceResource::make($invoice)-&gt;response()-&gt;setStatusCode(201);\n    }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Instead of an <code>InvoiceRepository<\/code>, the one reusable query lives on the model as a named scope, using the <code>#[Scope]<\/code> attribute: <code>Invoice::query()-&gt;inCurrency('PKR')<\/code>.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>#[Scope]\nprotected function inCurrency(Builder $query, string $currency): void\n{\n    $query-&gt;where('currency', $currency);\n}<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"domain-driven-design\">How much domain-driven design Laravel needs<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Laravel domain driven design rarely needs the full vocabulary of aggregates, repositories and factories from the textbooks. A few ideas carry most of the value:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n\n<li><strong>Bounded contexts as folders.<\/strong> <code>Billing<\/code> and <code>Catalog<\/code> can each keep their own meaning of <code>Product<\/code>.<\/li>\n\n\n<li><strong>The business&#8217;s language in class names.<\/strong> <code>IssueInvoice<\/code> beats <code>InvoiceManager::process<\/code>. If your finance team says &#8220;issue,&#8221; so does the code.<\/li>\n\n\n<li><strong>Value objects for things with rules.<\/strong> Money, line items, email addresses and date ranges are small immutable classes that validate themselves.<\/li>\n\n\n<li><strong>Domain events for side effects.<\/strong> The action records that something happened. Listeners decide what follows, and new side effects don&#8217;t touch the action.<\/li>\n\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">In most Laravel apps I&#8217;d skip separate persistence models mapped to pure domain entities, and a repository per aggregate. For a product still finding its shape, they double the code you change for every new field.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"testing-strategy\">A testing strategy that matches the architecture<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Each layer gets the test type that fits it, so a failure points at one layer and the suite stays fast enough for every push.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Layer<\/th><th>Test type<\/th><th>What it proves<\/th><\/tr><\/thead><tbody><tr><td>HTTP (routes, requests, resources)<\/td><td>Feature tests through <code>postJson<\/code><\/td><td>Auth, validation messages, status codes, response shape<\/td><\/tr><tr><td>Actions<\/td><td>Feature tests calling the action directly, with a real database<\/td><td>Business rules, totals, numbering, events<\/td><\/tr><tr><td>Boundaries<\/td><td>A fake bound in the container<\/td><td>The domain works with any implementation of the contract<\/td><\/tr><tr><td>Structure<\/td><td>Pest arch tests<\/td><td>Layers don&#8217;t import what they shouldn&#8217;t<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"action-tests\">Testing actions against a real database<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Action tests use <code>RefreshDatabase<\/code> and real Eloquent. Mocking Eloquent tends to test the mock. The tax calculator is the only thing swapped, because it&#8217;s the boundary:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>beforeEach(function () {\n    \/\/ Swap the boundary, not Eloquent: 10% tax for every test in this file.\n    $this-&gt;app-&gt;instance(TaxCalculator::class, new FlatRateTax(1000));\n});\n\nit('totals lines and adds tax in minor units', function () {\n    $invoice = app(IssueInvoice::class)-&gt;handle('Acme Traders', 'AED', [\n        new InvoiceLineData('Setup', 1, 50_000),\n        new InvoiceLineData('Monthly seat', 3, 12_345),\n    ]);\n\n    expect($invoice-&gt;subtotal_minor)-&gt;toBe(87_035)\n        -&gt;and($invoice-&gt;tax_minor)-&gt;toBe(8_704) \/\/ 8,703.5 rounds half up\n        -&gt;and($invoice-&gt;total_minor)-&gt;toBe(95_739)\n        -&gt;and($invoice-&gt;lines)-&gt;toHaveCount(2);\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The full file also checks sequential numbering, that the event fires, that an empty invoice is refused and that the named scope filters by currency.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"feature-tests\">Testing the HTTP layer<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">API tests cover what only the HTTP layer can get wrong: guests get a 401, bad payloads get a 422 with field errors, and a valid request returns 201 with the right JSON.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>it('validates the payload before any business code runs', function () {\n    $this-&gt;actingAs(User::factory()-&gt;create(), 'sanctum')\n        -&gt;postJson('\/api\/invoices', ['currency' =&gt; 'usd', 'lines' =&gt; []])\n        -&gt;assertUnprocessable()\n        -&gt;assertJsonValidationErrors(['customer_name', 'currency', 'lines']);\n});<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"arch-tests\">Arch tests that enforce the boundaries<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Folder conventions erode one shortcut at a time. <a href=\"https:\/\/pestphp.com\/docs\/arch-testing\" target=\"_blank\" rel=\"noopener\">Pest&#8217;s architecture testing<\/a> turns them into failing tests. These five rules run in milliseconds:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>arch('domain code does not know about HTTP')\n    -&gt;expect('App\\Domains')\n    -&gt;not-&gt;toUse(['Illuminate\\Http', 'App\\Http']);\n\narch('actions are final and expose handle()')\n    -&gt;expect('App\\Domains\\Billing\\Actions')\n    -&gt;toBeFinal()\n    -&gt;toHaveMethod('handle');\n\narch('data objects are immutable')\n    -&gt;expect('App\\Domains\\Billing\\Data')\n    -&gt;toBeReadonly();\n\narch('the domain depends on its contract, not the implementation')\n    -&gt;expect('App\\Domains\\Billing\\Tax\\FlatRateTax')\n    -&gt;toOnlyBeUsedIn(['App\\Domains\\Billing\\BillingServiceProvider']);\n\narch('no debugging calls ship')\n    -&gt;expect(['dd', 'dump', 'ray', 'var_dump'])\n    -&gt;not-&gt;toBeUsed();<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">As a check that the rules bite, adding an <code>Illuminate\\Http\\Request<\/code> import to the action and removing <code>final<\/code> made two arch tests fail. Both passed again once the change was reverted.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"ci\">Run it in CI against your production database engine<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">SQLite in memory is fast for local runs. Run the same suite in CI against the engine you deploy on, MySQL or PostgreSQL, because locking, JSON columns and some index behavior differ between engines. The <code>lockForUpdate<\/code> call in the action, for example, does nothing on SQLite.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"tenancy-permissions\">Where tenancy and permissions fit<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">In a multi-tenant SaaS, tenancy and permissions are cross-cutting: every domain needs them, and none should implement them alone. EloSync, our CRM, is a Laravel API with a React front end, single-database tenancy and Spatie permissions. In a setup like that, the tenant scope and the permission checks belong in shared infrastructure (a trait on tenant-owned models and a middleware that sets tenant context). I covered the tenant global scope and per-tenant roles with tests in <a href=\"https:\/\/blog.shaz3e.com\/how-to-build-a-crm\/\">my guide to building a CRM<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two rules follow for any layout. Actions take the tenant from context set by middleware, never from request input, and authorization runs at the edge (policies or Form Request <code>authorize<\/code>) before an action starts.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"team-decision\">Architecture is a team decision<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Architecture choices outlive the developer who made them, so write each one down in a short decision record. Who owns those calls in a growing company is part of <a href=\"https:\/\/blog.shaz3e.com\/cto-responsibilities\/\">what a CTO actually owns<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The same thinking applies one level up. Every product we build has its own database, login and deployment, and starts from a copy of one starter template, as I explained in <a href=\"https:\/\/blog.shaz3e.com\/saas-starter-template-multiple-products\/\">how I build many SaaS products from one starter template<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"series\">More in this series<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This guide is the hub for a Laravel architecture series. I&#8217;ll link each post below as it goes live:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n\n<li>Building a modular monolith in Laravel<\/li>\n\n\n<li>Laravel action classes vs service classes: when to use each<\/li>\n\n\n<li>Removing the repository layer from a Laravel app<\/li>\n\n\n<li>Multiple database connections in Laravel done safely<\/li>\n\n\n<li>Using data transfer objects in Laravel<\/li>\n\n\n<li>Enforcing architecture rules in PHP with Pest arch tests<\/li>\n\n\n<li>The Laravel service container and contextual binding explained<\/li>\n\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"faq\">FAQ<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"faq-structure\">What is the best folder structure for a large Laravel application?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">For one deployable app with many business areas, group business code by area under <code>app\/Domains<\/code> and keep the HTTP layer in <code>app\/Http<\/code>. Use full modules when separate teams own separate areas or features get switched on per customer.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"faq-repository\">Should I use the repository pattern with Eloquent?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Usually not. A repository that wraps Eloquent one-to-one hides its query builder and eager loading. Use named scopes for reusable queries, and keep repositories for data that comes from outside your own database.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"faq-actions-services\">What&#8217;s the difference between an action and a service in Laravel?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">An action is one business operation with one public method. A service groups related operations or wraps an outside system. Start with actions for business rules and services for integrations.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"faq-ddd\">Is domain-driven design worth it in Laravel?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The light version is worth it: folders per business area, business names in code, value objects and domain events. Full DDD with separate persistence models fits complex, stable domains better than products that are still changing every week.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p class=\"wp-block-paragraph\"><em><a href=\"https:\/\/shaz3e.com\" target=\"_blank\" rel=\"noopener\">Shahrukh Akhtar (Shaz3e)<\/a> is CTO &amp; Co-Founder of Diligent Creators Pvt. Ltd., building EloSync and other SaaS products. Find him on <a href=\"https:\/\/x.com\/shaz3e\" target=\"_blank\" rel=\"noopener\">X<\/a>, <a href=\"https:\/\/linkedin.com\/in\/shaz3e\" target=\"_blank\" rel=\"noopener\">LinkedIn<\/a>, <a href=\"https:\/\/peerlist.io\/shaz3e\" target=\"_blank\" rel=\"noopener\">Peerlist<\/a> and <a href=\"https:\/\/github.com\/Shaz3e\" target=\"_blank\" rel=\"noopener\">GitHub<\/a>.<\/em><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Laravel architecture best practices for large applications: default, domain and module folder layouts compared, when to use actions, services or repositories, light domain-driven design, and a testing strategy with Pest arch tests, shown in a tested Laravel 13 billing example.<\/p>\n","protected":false},"author":2,"featured_media":131,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_kad_post_transparent":"","_kad_post_title":"","_kad_post_layout":"","_kad_post_sidebar_id":"","_kad_post_content_style":"","_kad_post_vertical_padding":"","_kad_post_feature":"","_kad_post_feature_position":"","_kad_post_header":false,"_kad_post_footer":false,"_kad_post_classname":"","_jetpack_newsletter_access":"","_jetpack_dont_email_post_to_subs":true,"_jetpack_newsletter_tier_id":0,"_jetpack_memberships_contains_paywalled_content":false,"_jetpack_feature_clip_id":0,"_jetpack_memberships_contains_paid_content":false,"footnotes":"","jetpack_post_was_ever_published":false},"categories":[7],"tags":[],"class_list":["post-56","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-laravel-php"],"jetpack_sharing_enabled":true,"jetpack_likes_enabled":true,"jetpack_shortlink":"https:\/\/wp.me\/phsTKQ-U","jetpack_featured_media_url":"https:\/\/i0.wp.com\/www.shaz3e.com\/blog\/wp-content\/uploads\/2026\/10\/laravel-architecture-best-practices-1200x630-1.jpg?fit=1200%2C630&ssl=1","_links":{"self":[{"href":"https:\/\/www.shaz3e.com\/blog\/wp-json\/wp\/v2\/posts\/56","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.shaz3e.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.shaz3e.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.shaz3e.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/www.shaz3e.com\/blog\/wp-json\/wp\/v2\/comments?post=56"}],"version-history":[{"count":1,"href":"https:\/\/www.shaz3e.com\/blog\/wp-json\/wp\/v2\/posts\/56\/revisions"}],"predecessor-version":[{"id":58,"href":"https:\/\/www.shaz3e.com\/blog\/wp-json\/wp\/v2\/posts\/56\/revisions\/58"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.shaz3e.com\/blog\/wp-json\/wp\/v2\/media\/131"}],"wp:attachment":[{"href":"https:\/\/www.shaz3e.com\/blog\/wp-json\/wp\/v2\/media?parent=56"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.shaz3e.com\/blog\/wp-json\/wp\/v2\/categories?post=56"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.shaz3e.com\/blog\/wp-json\/wp\/v2\/tags?post=56"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}