Chapter Separation of Concerns di Controller sudah menunjukkan pemisahan validasi dan logika dari satu controller. Section ini mengangkat topik tersebut ke tingkat aplikasi penuh: aplikasi yang sehat bukan sekadar controller yang tipis, melainkan keseluruhan kode yang tersusun dalam layer-layer dengan tanggung jawab yang jelas. Kita mulai dengan memetakan layer tersebut, mengenali gejala concern yang tercampur, lalu merapikan satu controller yang terjalin menjadi alur yang setiap bagiannya bisa berubah sendiri tanpa menyeret bagian lain.
Peta Layer Aplikasi Laravel #
Sebuah request tidak melompat dari URL ke database. Ia melewati beberapa layer, dan setiap layer menjawab satu pertanyaan:
| Layer | Menjawab | Penghuni khas |
|---|---|---|
| HTTP | “Input ini sah dan siapa penggunanya?” | route, middleware, form request, controller |
| Application | “Langkah apa yang dijalankan untuk use case ini?” | service class, action class, job |
| Domain | “Aturan bisnis apa yang berlaku?” | model Eloquent, kelas domain murni |
| Data | “Bagaimana data disimpan dan diambil?” | Eloquent query, migration, query builder |
Arah dependensi antar layer mengikuti satu aturan sederhana: layer luar boleh mengenal layer dalam, tidak pernah sebaliknya. Controller boleh memanggil service, service boleh memakai model, tetapi model tidak boleh tahu bahwa request HTTP ada — model tidak membaca Request, tidak membangun response, dan tidak mengirim email. Nama folder bawaan Laravel memang tidak memisahkan layer secara eksplisit (app/Http, app/Models, app/Services), tetapi struktur request lifecycle
dan konvensi app/ yang dijelaskan di dokumentasi struktur
memetakan dengan jelas bagian mana yang hidup di lingkaran HTTP dan bagian mana yang netral — kita yang menjaga garis itu dengan disiplin penempatan kode.
Tanda-Tanda Concern Tercampur #
Pemisahan yang bocor meninggalkan jejak yang mudah dikenali. Controller yang berbicara langsung ke database (User::where(...) di dalam method), model yang mengirim email dari event-nya sendiri, dan aturan validasi yang sama ditulis ulang di tiga controller — semuanya menandakan tanggung jawab yang saling merapat. Gejala lain muncul saat kita membuka git blame: satu berkas yang berubah setiap sprint karena setiap fitur baru menulis barisnya di sana. Ciri yang paling telanjang: satu perubahan kecil memaksa kita membuka banyak berkas yang seharusnya tidak berhubungan — aturan slug di controller, tampilan error di model, pengiriman email di view helper, semuanya saling menuntut disentuh bersamaan.
Refaktor Satu Controller yang Terjalin #
Berikut wujud controller seperti itu — divalidasi manual, menyimpan data, mengirim email, sekaligus menyusun response, semuanya dalam satu method. Kode ini sengaja ditampilkan apa adanya, bukan untuk ditiru:
// Contoh tampilan — pola yang perlu kita hindari
public function simpan(Request $request): RedirectResponse
{
$data = $request->all();
if (strlen($data['title']) < 5 || strlen($data['title']) > 120) {
return back()->withErrors(['title' => 'Judul 5-120 karakter.']);
}
if (strlen($data['body']) < 20) {
return back()->withErrors(['body' => 'Isi minimal 20 karakter.']);
}
$post = Post::create([
'user_id' => $request->user()->id,
'title' => $data['title'],
'slug' => Str::slug($data['title']).'-'.substr(sha1($data['title'].$data['body']), 0, 6),
'body' => $data['body'],
]);
Mail::to('[email protected]')->send(new PostBaruMail($post));
Log::info('Post diterbitkan', ['id' => $post->id]);
return redirect()->route('posts.show', $post)
->with('status', "Post \"{$post->title}\" berhasil diterbitkan.");
}Logika “cara menerbitkan post” terkunci di balik HTTP: queue job dan Artisan command tidak bisa memakainya tanpa menyalin barisnya. Refaktornya memecah method tersebut menurut layer. Validasi naik ke SimpanPostRequest, pekerjaan penerbitan turun ke PostPublisher, dan pengiriman email keluar dari controller — tempat paling wajar baginya adalah service yang sama atau listener event PostDiterbitkan (kita bahas di Section Events), sehingga contoh ini tetap fokus pada dua pemisahan utama:
<?php
namespace App\Services;
use App\Models\Post;
use App\Models\User;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class PostPublisher
{
public function publish(User $author, string $title, string $body): Post
{
$post = Post::create([
'user_id' => $author->id,
'title' => $title,
'slug' => Str::slug($title).'-'.substr(sha1($title.$body), 0, 6),
'body' => $body,
]);
Log::info('Post diterbitkan', ['id' => $post->id, 'slug' => $post->slug]);
return $post;
}
}Controller menyusut menjadi koordinator murni — validasi berjalan sebelum method dieksekusi, service mengerjakan logika, controller tinggal menyiapkan redirect:
<?php
namespace App\Http\Controllers;
use App\Http\Requests\SimpanPostRequest;
use App\Models\Post;
use App\Services\PostPublisher;
use Illuminate\Http\RedirectResponse;
class PostController extends Controller
{
public function __construct(
protected PostPublisher $publisher,
) {}
public function simpan(SimpanPostRequest $request): RedirectResponse
{
$post = $this->publisher->publish(
author: $request->user(),
title: $request->input('title'),
body: $request->input('body'),
);
return redirect()
->route('posts.show', $post)
->with('status', "Post \"{$post->title}\" berhasil diterbitkan.");
}
public function show(Post $post): string
{
return "Post: {$post->title} (slug: {$post->slug})";
}
}Feature test memastikan kedua sisi pemisahan bekerja: input valid sampai ke database lewat service, dan input tidak valid ditolak sebelum menyentuh service sama sekali:
<?php
namespace Tests\Feature;
use App\Models\Post;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PublishPostTest extends TestCase
{
use RefreshDatabase;
public function test_post_valid_diterbitkan_lewat_service(): void
{
$budi = User::factory()->create(['name' => 'Budi']);
$response = $this->actingAs($budi)->post('/posts', [
'title' => 'Belajar Arsitektur Laravel',
'body' => 'Materi tentang pemisahan tanggung jawab antar layer aplikasi.',
]);
$response->assertRedirectToRoute('posts.show', Post::sole());
$response->assertSessionHas('status', 'Post "Belajar Arsitektur Laravel" berhasil diterbitkan.');
$this->assertStringStartsWith('belajar-arsitektur-laravel', Post::sole()->slug);
}
public function test_post_tidak_valid_ditolak_sebelum_menyentuh_service(): void
{
$budi = User::factory()->create(['name' => 'Budi']);
$response = $this->actingAs($budi)->from('/posts/create')->post('/posts', [
'title' => 'abc',
'body' => 'Terlalu singkat.',
]);
$response->assertRedirect('/posts/create');
$response->assertSessionHasErrors(['title', 'body']);
$this->assertDatabaseCount('posts', 0);
}
}Output yang Dihasilkan:
PASS Tests\Feature\PublishPostTest
✓ post valid diterbitkan lewat service 0.17s
✓ post tidak valid ditolak sebelum menyentuh service 0.01s
Tests: 2 passed (11 assertions)
Duration: 0.26sApa yang Kita Dapat dari Pemisahan #
Riak perubahan menjadi terkurung. Aturan slug berubah? Cukup PostPublisher yang disentuh. Perlu menerbitkan post dari queue job atau Artisan command? Panggil service yang sama tanpa request HTTP. Setiap layer juga menjadi seam yang bisa diuji — service dites tanpa booting HTTP penuh, form request dites tanpa database, dan container swap dari chapter Dependency Injection mengganti kelas di balik interface tanpa menyentuh konsumennya. Efek samping yang jarang disadari: dua orang bisa bekerja paralel pada layer berbeda karena kontrak antar layer jelas, tanpa saling menimpa berkas yang sama.
Pemisahan tanggung jawab ini bertumpu pada satu fasilitas yang selama ini kita pakai diam-diam: siapa yang membuat PostPublisher masuk ke constructor controller? Jawabannya adalah service container, mesin resolusi dependency Laravel yang kita bedah di chapter berikutnya.