Laravel Policies: Otorisasi Berbasis Model
TutorialLaravelPHP#laravel#php#backend#best-practices

Laravel Policies: Otorisasi Berbasis Model

A
Abd. Asis
Bagikan:

Cek if ($user->id === $post->user_id) di controller, lalu cek yang sama di Blade, lalu cek lagi di form request — kalau ini terasa familiar, kita punya masalah yang sama. Logika otorisasi yang tersebar di mana-mana bukan hanya susah dipelihara, tapi juga rawan ketinggalan satu titik. Laravel Policies hadir untuk menyelesaikan persis masalah ini: satu tempat, satu kebenaran, untuk aturan "siapa boleh melakukan apa" terhadap sebuah model.

Artikel ini membahas Laravel Policies secara menyeluruh — dari setup awal di Laravel 13, penggunaan di berbagai lapisan aplikasi, Response object untuk kontrol penuh, hingga pola yang siap dipakai di aplikasi multi-tenant SaaS.

Policies vs Gates: Kapan Pakai yang Mana

Laravel menyediakan dua mekanisme otorisasi: Gates dan Policies. Gates adalah closure sederhana yang cocok untuk aturan yang tidak terikat model tertentu. Policies adalah class yang mengorganisasi aturan otorisasi di sekitar satu model atau resource.

Analoginya sederhana: kalau Gates seperti route closure, Policies seperti controller — keduanya berfungsi, tapi untuk kompleksitas yang berbeda.

Pakai Policy ketika:

  • Aturan terikat pada model spesifik: Article, Invoice, Team, Project
  • Ada banyak kemungkinan aksi: view, create, update, delete, publish
  • Logic otorisasi perlu konsisten di controller, Blade, API, dan form request
  • Aturan cukup kompleks untuk layak ditest secara terpisah

Membuat Policy Pertama

Generate policy dengan Artisan, sertakan flag --model agar scaffold langsung menyesuaikan nama model:

BashBASH
php artisan make:policy ArticlePolicy --model=Article

Laravel akan membuat app/Policies/ArticlePolicy.php dengan method standar yang sudah terhubung ke model Article:

PHPPHP
<?php

namespace App\Policies;

use App\Models\Article;
use App\Models\User;

class ArticlePolicy
{
    public function viewAny(User $user): bool
    {
        return true;
    }

    public function view(User $user, Article $article): bool
    {
        return true;
    }

    public function create(User $user): bool
    {
        return $user->hasVerifiedEmail();
    }

    public function update(User $user, Article $article): bool
    {
        return $user->id === $article->author_id;
    }

    public function delete(User $user, Article $article): bool
    {
        return $user->id === $article->author_id;
    }
}

Perhatikan bahwa viewAny dan create tidak menerima instance model — karena aksi tersebut tidak terikat pada resource yang sudah ada.

Auto-Discovery di Laravel 13

Laravel 13 menemukan policy secara otomatis selama mengikuti konvensi penamaan App\Models\ArticleApp\Policies\ArticlePolicy. Tidak perlu registrasi manual di AuthServiceProvider untuk kebanyakan kasus.

Jika perlu mapping kustom atau policy ada di lokasi non-standar, gunakan Gate::guessPolicyNamesUsing() di AppServiceProvider:

PHPPHP
Gate::guessPolicyNamesUsing(function (string $modelClass): string {
    return 'App\\Authorization\\' . class_basename($modelClass) . 'Policy';
});

Menggunakan Policy di Berbagai Lapisan

Policy yang sudah dibuat harus digunakan secara konsisten di semua lapisan — controller, route, Blade, dan form request.

Di Controller

Cara paling ekspresif menggunakan $this->authorize():

PHPPHP
public function update(ArticleUpdateRequest $request, Article $article): RedirectResponse
{
    $this->authorize('update', $article);

    $article->update($request->validated());

    return redirect()->route('articles.show', $article);
}

Jika user tidak memiliki izin, Laravel otomatis melempar AuthorizationException yang dikonversi ke respons HTTP 403. Alternatifnya pakai Gate facade langsung — keduanya memanggil policy yang sama:

PHPPHP
use Illuminate\Support\Facades\Gate;

public function destroy(Article $article): RedirectResponse
{
    Gate::authorize('delete', $article);

    $article->delete();

    return redirect()->route('articles.index');
}

Di Route

Proteksi di level route memastikan otorisasi berjalan sebelum controller dipanggil:

PHPPHP
Route::put('/articles/{article}', [ArticleController::class, 'update'])
    ->middleware('can:update,article');

// atau dengan fluent method
Route::delete('/articles/{article}', [ArticleController::class, 'destroy'])
    ->can('delete', 'article');

Di Blade

Otorisasi harus tercermin di UI — tidak hanya di backend. Gunakan directive Blade agar tombol dan link yang akan selalu gagal tidak ditampilkan sama sekali:

Laravel BladeBLADE
@can('update', $article)
    <a href="{{ route('articles.edit', $article) }}" class="btn-primary">
        Edit Artikel
    </a>
@endcan

@can('delete', $article)
    <form method="POST" action="{{ route('articles.destroy', $article) }}">
        @csrf @method('DELETE')
        <button type="submit">Hapus</button>
    </form>
@endcan

Directive @canany berguna jika ingin menampilkan elemen ketika user memiliki salah satu dari beberapa kemampuan:

Laravel BladeBLADE
@canany(['update', 'delete'], $article)
    <div class="action-toolbar">
        {{-- tombol aksi --}}
    </div>
@endcanany

Di Form Request

Form request adalah tempat yang tepat untuk menyatukan validasi dan otorisasi dalam satu class:

PHPPHP
<?php

namespace App\Http\Requests;

use App\Models\Article;
use Illuminate\Foundation\Http\FormRequest;

class ArticleUpdateRequest extends FormRequest
{
    public function authorize(): bool
    {
        /** @var Article $article */
        $article = $this->route('article');

        return $this->user()->can('update', $article);
    }

    public function rules(): array
    {
        return [
            'title'   => ['required', 'string', 'max:200'],
            'body'    => ['required', 'string'],
            'status'  => ['required', 'in:draft,published'],
        ];
    }
}

Dengan pola ini, controller bisa sangat tipis — tidak perlu lagi $this->authorize() secara terpisah.

Response Object untuk Kontrol Penuh

Mengembalikan true atau false dari policy cukup untuk kasus sederhana. Tapi di aplikasi nyata, kita sering butuh pesan error yang spesifik atau HTTP status code yang berbeda. Kelas Illuminate\Auth\Access\Response menyelesaikan ini.

Pesan Error Kustom

PHPPHP
use Illuminate\Auth\Access\Response;

public function update(User $user, Article $article): Response
{
    if ($user->id === $article->author_id) {
        return Response::allow();
    }

    if ($user->hasRole('editor') && $article->team_id === $user->team_id) {
        return Response::allow();
    }

    return Response::deny('Hanya penulis atau editor tim yang bisa mengubah artikel ini.');
}

Pesan dari Response::deny() bisa diakses di exception handler untuk ditampilkan ke user.

Sembunyikan Resource dengan 404

Untuk resource sensitif, lebih baik mengembalikan 404 daripada 403 — agar keberadaan resource tidak bocor ke user yang tidak berhak:

PHPPHP
public function view(User $user, Article $article): Response
{
    if ($article->is_published) {
        return Response::allow();
    }

    if ($user->id === $article->author_id) {
        return Response::allow();
    }

    // User tidak tahu apakah artikel ini ada atau tidak
    return Response::denyAsNotFound();
}

denyAsNotFound() sangat berguna untuk halaman detail resource privat. User yang tidak berhak mendapat 404 biasa, sehingga tidak ada informasi yang bocor tentang keberadaan resource tersebut.

Logic Domain yang Kompleks

Untuk skenario yang melibatkan peran, kepemilikan, dan relasi, policy bisa mengekspresikan aturan bisnis secara eksplisit:

PHPPHP
public function publish(User $user, Article $article): Response
{
    if ($article->status === 'published') {
        return Response::deny('Artikel sudah dipublikasikan.');
    }

    if ($user->id === $article->author_id) {
        return Response::allow();
    }

    if ($user->hasRole('editor') && $article->team_id === $user->team_id) {
        return Response::allow();
    }

    return Response::deny('Hanya penulis atau editor tim yang bisa mempublikasikan artikel.');
}

Dibanding if yang tersebar di controller, semua aturan terbaca jelas dalam satu method.

Method before() untuk Super Admin dan Aturan Global

Method before() berjalan sebelum method policy lainnya. Ini tempat yang tepat untuk aturan global seperti super admin yang boleh melakukan segalanya, atau akun yang diblokir:

PHPPHP
public function before(User $user, string $ability): ?bool
{
    if ($user->isSuspended()) {
        return false;
    }

    if ($user->hasRole('super-admin')) {
        return true;
    }

    return null; // lanjut ke method policy spesifik
}

Mengembalikan null dari before() penting — ini yang membuat Laravel melanjutkan ke method seperti update() atau delete(). Jangan kembalikan false jika hanya ingin membiarkan flow berjalan normal.

Dengan pola ini, method-method lain di policy bisa fokus pada aturan bisnis tanpa perlu selalu mengecek apakah user adalah admin.

Pola Multi-Tenant SaaS

Policy benar-benar bersinar di aplikasi multi-tenant. Bayangkan platform SaaS di mana setiap Organization punya data masing-masing dan user hanya boleh mengakses data di organisasinya:

PHPPHP
<?php

namespace App\Policies;

use App\Models\Report;
use App\Models\User;
use Illuminate\Auth\Access\Response;

class ReportPolicy
{
    public function before(User $user, string $ability): ?bool
    {
        if ($user->isSuspended()) {
            return false;
        }

        return null;
    }

    public function view(User $user, Report $report): Response
    {
        if ($user->organization_id !== $report->organization_id) {
            return Response::denyAsNotFound();
        }

        return Response::allow();
    }

    public function update(User $user, Report $report): Response
    {
        if ($user->organization_id !== $report->organization_id) {
            return Response::denyAsNotFound();
        }

        if (!$user->hasPermissionTo('reports.update')) {
            return Response::deny('Akun kamu tidak memiliki izin mengubah laporan.');
        }

        return Response::allow();
    }

    public function delete(User $user, Report $report): Response
    {
        if ($user->organization_id !== $report->organization_id) {
            return Response::denyAsNotFound();
        }

        if ($user->hasRole('org-admin')) {
            return Response::allow();
        }

        if ($user->id === $report->created_by) {
            return Response::allow();
        }

        return Response::deny('Hanya admin organisasi atau pembuat laporan yang bisa menghapus.');
    }
}

Pola ini bisa direplikasi ke model lain di domain yang sama: Project, Invoice, Team, Contract. Sekali pola tenant-aware terbentuk, strukturnya konsisten di seluruh codebase.

PHP Attributes di Laravel 13

Laravel 13 memperkenalkan PHP attributes #[Authorize] dan #[Middleware] di controller, membuat otorisasi lebih deklaratif dan colocated dengan action yang dilindungi:

PHPPHP
<?php

namespace App\Http\Controllers;

use App\Models\Article;
use Illuminate\Routing\Attributes\Controllers\Authorize;
use Illuminate\Routing\Attributes\Controllers\Middleware;

#[Middleware('auth')]
class ArticleController
{
    public function index(): \Inertia\Response
    {
        // semua user terautentikasi bisa lihat daftar
    }

    #[Authorize('update', Article::class)]
    public function update(ArticleUpdateRequest $request, Article $article): \Illuminate\Http\RedirectResponse
    {
        $article->update($request->validated());

        return redirect()->route('articles.show', $article);
    }

    #[Authorize('delete', Article::class)]
    public function destroy(Article $article): \Illuminate\Http\RedirectResponse
    {
        $article->delete();

        return redirect()->route('articles.index');
    }
}

Dengan attributes, developer baru bisa memahami lifecycle lengkap sebuah request — middleware dan otorisasi — hanya dengan membaca class controller, tanpa perlu buka file route.

Kesalahan Umum yang Perlu Dihindari

Beberapa pola yang sering membuat policy tidak efektif:

KesalahanDampakSolusi
Logic otorisasi di controller, bukan policyDuplikasi, susah ditestPindahkan ke policy method
Cek role saja, abaikan kepemilikanUser beda tenant bisa aksesSelalu gabungkan role + organization_id/user_id
Proteksi controller tapi lupa BladeUI tampilkan aksi yang akan gagalPakai @can/@cannot konsisten
Return false dari before() untuk semuaAdmin pun tidak bisa aksesReturn null untuk flow normal
Tidak pakai Response objectPesan error generik, 403 untuk semuaGunakan deny(), denyAsNotFound()

Checklist Implementasi

Sebelum dianggap selesai, pastikan setiap policy memenuhi poin berikut:

  • Policy di-generate dengan php artisan make:policy --model
  • before() menangani super admin dan akun yang diblokir
  • Setiap method return Response object, bukan bool mentah
  • $this->authorize() dipanggil di controller atau authorize() di form request
  • Route sensitif dilindungi dengan ->can() atau middleware can:
  • Directive @can/@cannot dipakai di Blade untuk semua aksi
  • Di aplikasi multi-tenant, setiap method mengecek organization_id atau padanannya

Kesimpulan

Policies mengubah logika otorisasi dari sesuatu yang tersebar dan rawan bug menjadi kode yang terpusat, mudah dibaca, dan siap ditest. Semakin kompleks domain aplikasi — multi-tenant, role bertingkat, resource privat — semakin besar manfaat yang didapat dari policy yang terstruktur. Untuk langkah berikutnya, eksplorasi integrasi policy dengan Spatie Laravel Permission bisa menjadi pelengkap yang kuat untuk manajemen role dan permission yang lebih granular.

Referensi

  1. 1 Laravel 13 — Authorization (Policies & Gates)
  2. 2 Laravel 13 — Form Request Authorization
  3. 3 Spatie Laravel Permission — Role & Permission Management
Abd. Asis
Ditulis oleh
Abd. Asis

Software Developer dari Madura. Menulis tentang PHP, Laravel, dan pengembangan web modern dalam Bahasa Indonesia.