Struktur bawaan Laravel mengelompokkan kode berdasarkan jenis-nya: semua controller di app/Http/Controllers, semua model di app/Models, semua action di app/Actions. Pada aplikasi kecil pengelompokan ini ringan dan mudah diikuti. Masalah muncul ketika satu fitur — katakanlah blog — menyebar ke lima folder berbeda, dan fitur tagihan menyebar ke lima folder yang sama: mengubah satu fitur berarti membuka lima direktori sekaligus. Domain organization membalik sudut pandangnya — mengelompokkan kode berdasarkan fitur, bukan jenis, sehingga semua yang milik blog hidup di satu tempat: app/Blog/. Chapter ini melakukan reorganisasi sungguhan di aplikasi sandbox kita dan membuktikan test tetap hijau sebelum, selama, dan sesudah pemindahan.

Struktur Default: Berdasarkan Layer #

Berikut isi penuh folder app/ pada skeleton Laravel 13 yang baru diinstal:

text
app/
├── Http/Controllers/Controller.php
├── Models/User.php
└── Providers/AppServiceProvider.php

Tiga berkas — itulah seluruhnya. Sejak Laravel 11, skeleton sengaja datar: folder app/ hampir kosong, dan folder seperti app/Http/Middleware atau app/Console/Commands baru dibuat saat ada perintah make:* yang membutuhkannya. Kedataran ini kabar baik untuk domain organization: tidak banyak struktur bawaan yang harus dilawan. Namun perintah make:model tetap menaruh hasilnya di app/Models dan make:controller di app/Http/Controllers — konvensi layer tetap menjadi default yang aktif.

Alternatif: Berdasarkan Domain #

Prinsipnya sederhana: satu folder per domain fitur, dan di dalamnya kode diatur menurut jenis. Berikut struktur aplikasi sandbox kita setelah domain Blog dan Billing dipisahkan:

text
app/
├── Actions/PlaceOrder.php
├── Billing/
│   ├── Actions/IssueInvoice.php
│   └── Models/Invoice.php
├── Blog/
│   ├── Actions/PublishPost.php
│   ├── Exceptions/PostSudahTerbit.php
│   ├── Http/Controllers/PostController.php
│   └── Models/Post.php
├── Console/Commands/BuatPesananCommand.php
├── Exceptions/KeranjangKosong.php
├── Http/Controllers/{Controller,OrderController}.php
├── Jobs/BuatPesananJob.php
├── Models/{Order,OrderItem,User}.php
└── Providers/AppServiceProvider.php

Semua kode milik blog kini berada dalam satu folder app/Blog/ — action, controller, model, sampai exception khususnya. Perhatikan bahwa struktur ini inkremental: PlaceOrder masih tinggal di app/Actions gaya lama, User tetap di app/Models karena dipakai lintas domain, dan keduanya sama sekali tidak mengganggu. Reorganisasi tidak harus selesai sekaligus.

Melakukan Reorganisasi #

Pemindahan satu kelas terdiri dari dua langkah: memindahkan berkas, lalu mengubah namespace agar sesuai lokasi barunya. PublishPost pindah ke app/Blog/Actions/PublishPost.php dengan namespace baru App\Blog\Actions:

php
<?php

namespace App\Blog\Actions;

use App\Blog\Exceptions\PostSudahTerbit;
use App\Blog\Models\Post;

class PublishPost
{
    public function __invoke(Post $post): Post
    {
        if ($post->published_at !== null) {
            throw new PostSudahTerbit($post->slug);
        }

        $post->update(['published_at' => now()]);

        return $post;
    }
}

Tidak ada satu baris pun di composer.json yang perlu diubah. Mapping PSR-4 bawaan tetap App\ menunjuk app/, sehingga subfolder otomatis menjadi subnamespace — app/Blog/Models/Post.php adalah App\Blog\Models\Post tanpa konfigurasi tambahan.

Route ikut dipisah ke routes/blog.php, lalu didaftarkan lewat parameter then pada withRouting() di bootstrap/app.php:

php
<?php

// bootstrap/app.php — bagian withRouting
->withRouting(
    web: __DIR__.'/../routes/web.php',
    commands: __DIR__.'/../routes/console.php',
    health: '/up',
    then: function () {
        Route::middleware('web')
            ->group(base_path('routes/blog.php'));
    },
)

route:list memastikan route tetap terdaftar dengan controller di namespace barunya:

Output yang Dihasilkan:

text
$ php artisan route:list --path=posts

  POST       posts/{post}/publish .................. posts.publish › App\Blog\Http\Controllers\PostController@publish

                                                            Showing [1] routes

Perintah make:* juga perlu diberi namespace lengkap. Tanpa prefiks App\, Laravel menempelkan lokasi default sehingga hasilnya salah:

Output yang Dihasilkan:

text
$ php artisan make:model 'Billing\Models\Invoice'

   INFO  Model [app/Models/Billing/Models/Invoice.php] created successfully.

$ php artisan make:model 'App\Billing\Models\Invoice'

   INFO  Model [app/Billing/Models/Invoice.php] created successfully.

Invokasi kedua yang benar: sebut kelas lengkap dari App\ agar berkas mendarat di dalam domain. Keseluruhan reorganisasi di atas ditutup dengan satu kali php artisan test:

Output yang Dihasilkan:

text
  PASS  Tests\Feature\BillingDomainTest
  ✓  issue invoice membuat tagihan pesanan                         0.15s

  PASS  Tests\Feature\BlogDomainTest
  ✓  post bisa terbit lewat route                                   0.02s
  ✓  post kedua kalinya ditolak                                    0.02s

  PASS  Tests\Feature\PlaceOrderActionTest
  ✓  action bisa dites langsung tanpa http                          0.01s

  Tests:    10 passed (20 assertions)
  Duration: 0.34s

Apa yang Pindah, Apa yang Tinggal #

KodeKeputusan
Controller, model, action, exception milik satu fiturPindah ke folder domainnya
Model lintas domain (User), base Controller, providerTetap di lokasi bersama
Migrasi databaseTetap terpusat di database/migrations — urutan kronologis lintas domain harus tetap satu alur
RouteBoleh dipecah per domain (routes/blog.php, routes/billing.php)
View BladeOpsional: resources/views/blog/ agar template juga mengelompok

Alasan baris migrasi layak dijelaskan: migration berjalan berdasarkan urutan waktu, dan dua domain yang sama-sama menyentuh tabel yang sama akan saling bergantung urutannya. Menyatukan semua migrasi di satu folder menjaga sejarah schema tetap linear dan mudah dibaca.

Kapan Pindah, Kapan Bertahan #

Domain organization membayar dirinya ketika aplikasi punya sekitar sepuluh model atau lebih dan tim terbagi menurut fitur — satu orang memelihara blog, orang lain memelihara tagihan. Setiap tim menggarap foldernya sendiri, konflik merge menurun, dan keputusan bisa dibaca dari struktur. Di bawah ambang tersebut, struktur default lebih murah: folder app/Models berisi 15 berkas masih lebih mudah dinavigasi daripada 15 folder domain yang masing-masing berisi satu model.

Ada pula jalan tengah untuk klaster yang baru membesar: tetap berada di struktur layer, tetapi kelompokkan per domain di dalamnya — app/Services/Blog/, app/Services/Billing/. Biarkan klaster itu pindah menjadi domain penuh ketika anggotanya benar-benar banyak. Panduan resmi mengenai struktur bawaan tetap jadi rujukan di dokumentasi structure .

Struktur folder adalah wadah; isinya tetap kode yang harus lari di production. Wadah berikutnya yang kita rapikan bukan folder, melainkan sesuatu yang lebih kecil namun lebih sering disentuh: berkas konfigurasi dan environment.