DEV Community

Denis Augusto
Denis Augusto

Posted on

O if de permissão que você copiou pra 12 controllers

Ctrl+C, Ctrl+V, e mais uma vez

Toda aplicação Laravel tem essa linha. Geralmente várias cópias dela:

if ($post->user_id !== auth()->id()) {
    abort(403);
}
Enter fullscreen mode Exit fullscreen mode

Ela nasce inocente, no update() do controller. Aí você precisa da mesma checagem no destroy(). Copia. Depois no edit(). Copia. Aí o Blade precisa esconder o botão "Editar" de quem não pode. Copia de novo, agora em outro formato.

Quando você percebe, a regra "quem pode mexer nesse post" está espalhada em oito, dez, doze lugares. Nenhum deles sabe da existência dos outros.

E aí chega a segunda-feira em que o cliente pede: "admin também precisa poder editar qualquer post". Você já sentiu o frio na barriga? Porque agora você precisa achar todas as cópias. E vai esquecer uma. Sempre esquece uma.

O problema não é o if, é o endereço dele

Olha o controller depois de umas três rodadas de "só mais uma regrinha":

public function update(Request $request, Post $post)
{
    if ($post->user_id !== auth()->id() && ! auth()->user()->isAdmin()) {
        abort(403);
    }

    if ($post->publicado_em && $post->publicado_em->diffInHours(now()) > 24) {
        abort(403, 'Posts publicados há mais de 24h não podem ser editados.');
    }

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

    return back();
}
Enter fullscreen mode Exit fullscreen mode

Metade do método é autorização. E essa mesma metade precisa existir, idêntica, no destroy(), no edit() e na view.

Repara no que aconteceu: uma regra de negócio de verdade — "quem, e em que condições, pode alterar um post" — virou detalhe de implementação de um método HTTP. Ela não tem casa própria. Mora de favor em todo canto.

O conceito: autorização merece um endereço

Policy é uma classe que concentra as regras de autorização de um model. Um arquivo, um assunto: "o que cada usuário pode fazer com um Post".

O controller volta a fazer o que deveria: receber requisição, chamar o serviço, devolver resposta. E quando o cliente mudar de ideia sobre quem pode editar o quê, você abre um arquivo.

A solução: cria a policy e esquece o if

php artisan make:policy PostPolicy --model=Post
Enter fullscreen mode Exit fullscreen mode

O --model já gera o esqueleto com os métodos padrão (viewAny, view, create, update, delete...). Preenche o que interessa:

namespace App\Policies;

use App\Models\Post;
use App\Models\User;

class PostPolicy
{
    public function update(User $user, Post $post): bool
    {
        if ($user->isAdmin()) {
            return true;
        }

        if ($post->user_id !== $user->id) {
            return false;
        }

        // autor só edita nas primeiras 24h após publicar
        return ! $post->publicado_em
            || $post->publicado_em->diffInHours(now()) <= 24;
    }

    public function delete(User $user, Post $post): bool
    {
        return $user->isAdmin() || $post->user_id === $user->id;
    }
}
Enter fullscreen mode Exit fullscreen mode

Não precisa registrar nada: o Laravel descobre a policy sozinho pela convenção de nomes (PostPostPolicy). Se sua estrutura de pastas fugir do padrão, aí sim você registra manualmente com Gate::policy(Post::class, PostPolicy::class) no AppServiceProvider.

E o controller emagrece:

public function update(Request $request, Post $post)
{
    Gate::authorize('update', $post);

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

    return back();
}
Enter fullscreen mode Exit fullscreen mode

Uma linha. Se não puder, o Laravel lança AuthorizationException e devolve 403 sem você escrever nada.

Como usar na prática

No Blade, pra esconder o botão de quem não pode clicar:

@can('update', $post)
    <a href="{{ route('posts.edit', $post) }}">Editar</a>
@endcan
Enter fullscreen mode Exit fullscreen mode

Mesma regra, mesmo arquivo. Zero chance de a view discordar do controller.

Na rota, com o middleware can, quando o controller inteiro depende da permissão:

Route::put('/posts/{post}', [PostController::class, 'update'])
    ->middleware('can:update,post');
Enter fullscreen mode Exit fullscreen mode

Em qualquer lugar do código, com o próprio usuário:

if ($user->can('delete', $post)) {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Funciona em Job, em Livewire, em comando de console. A regra é uma só, e todo mundo pergunta pra ela.

As pegadinhas (essas pegam todo mundo)

1. O $this->authorize() não existe mais por padrão. Do Laravel 11 pra frente, o controller base do skeleton não traz mais o trait AuthorizesRequests. Se você seguiu um tutorial antigo e tomou "Call to undefined method", é isso. Ou usa Gate::authorize('update', $post), ou adiciona o trait no seu controller base:

use Illuminate\Foundation\Auth\Access\AuthorizesRequests;

abstract class Controller
{
    use AuthorizesRequests;
}
Enter fullscreen mode Exit fullscreen mode

2. Métodos sem model precisam do nome da classe. create e viewAny não recebem instância — afinal, o post ainda não existe. Então você passa a classe:

Gate::authorize('create', Post::class);
Enter fullscreen mode Exit fullscreen mode

Esquecer disso e passar $post num create é campeão de confusão.

3. O before() é poderoso demais. Dá pra liberar geral pro admin de uma vez:

public function before(User $user, string $ability): ?bool
{
    return $user->isAdmin() ? true : null;
}
Enter fullscreen mode Exit fullscreen mode

Só que atenção no retorno: true autoriza, false nega tudo e nem chama o resto da policy, e null deixa o fluxo continuar. Retornar false sem querer no before() derruba a policy inteira — e é um bug bem chato de achar, porque tudo simplesmente vira 403.

4. Visitante deslogado nem chega na policy. Por padrão o Laravel nega antes de executar o método. Se um post público pode ser visto por qualquer um, tipe o parâmetro como opcional:

public function view(?User $user, Post $post): bool
{
    return $post->publicado_em !== null || $user?->id === $post->user_id;
}
Enter fullscreen mode Exit fullscreen mode

Bônus: mensagem de erro que ajuda

Retornar false dá um 403 genérico. Às vezes você quer explicar o motivo:

use Illuminate\Auth\Access\Response;

public function update(User $user, Post $post): Response
{
    return $post->user_id === $user->id
        ? Response::allow()
        : Response::deny('Você só pode editar seus próprios posts.');
}
Enter fullscreen mode Exit fullscreen mode

A mensagem chega no front, e o usuário para de abrir chamado perguntando por que o botão não funciona.

Antes de você fechar a aba

Dá um grep por abort(403) no seu projeto agora. Cada resultado é uma regra de negócio morando no lugar errado, esperando o dia em que vai divergir das outras cópias.

A boa notícia: migrar é rápido. Cria a policy, move o if pra lá, e vai trocando os controllers um por um. Dá pra fazer em partes, sem parar o projeto.

E aí, seu projeto tem policy ou tá no esquema if copiado? Conta nos comentários — e se você já caiu na do before() retornando false, quero muito saber quanto tempo levou pra descobrir. 😄


Top comments (0)