Concluído Pessoal php laravel docker mysql

API de candidatos e vagas: um CRUD Laravel pensado para não repetir código

API de CRUD em Laravel 9 (PHP 8.0) para candidatos e vagas, com um detalhe que valeu o post: nenhuma entidade repete código. Controller, Service, Repository e Request partem de uma base abstrata, reaproveitada por Candidato, Vaga e o vínculo entre eles.

API de candidatos e vagas: um CRUD Laravel pensado para não repetir código

PHP 8.0 Laravel 9.x Laravel Passport MySQL Redis Docker PHPUnit MIT License

API de candidatos e vagas: um CRUD Laravel pensado para não repetir código

Esse é um projeto mais antigo — Laravel 9 com PHP 8.0, rodando em Docker com MySQL e Redis — mas ele carrega uma decisão de arquitetura que ainda uso hoje como referência: nenhuma entidade tem CRUD "na mão". Candidato, Vaga e o vínculo entre os dois (Vaga-Candidato) compartilham a mesma espinha dorsal de Controller, Service, Repository e Request, e cada classe concreta se resume a poucas linhas dizendo qual model ou quais regras de validação são específicas dela.

A API resolve um problema simples de descrever e chato de implementar bem: cadastrar candidatos, cadastrar vagas, vincular um ao outro, e permitir buscar qualquer um desses recursos por filtros dinâmicos vindos da query string, com paginação e ordenação configuráveis. Autenticação via Laravel Passport (OAuth2), documentação de rotas via uma coleção do Insomnia versionada no repositório.

A ideia central: quatro camadas abstratas, zero repetição

Em vez de escrever index, store, update, destroy de novo para cada recurso, o projeto define uma classe base abstrata por camada. As classes concretas (CandidatoController, VagaService, VagaCandidatoRepository etc.) praticamente só injetam a dependência certa no construtor:

Camada Classe base O que ela resolve de uma vez só Request BaseRequest Escolhe as regras de validação certas conforme o verbo HTTP (GET, POST, PUT, DELETE) e aplica nomes amigáveis nos erros Controller BaseController Implementa index/show/store/update/destroy/massDestroy padronizados, sempre devolvendo JSON com o status certo Service BaseService Traduz a query string em filtros, aplica paginação/ordenação e centraliza o tratamento de erro em uma exceção própria Repository BaseRepository Isola o Eloquent: getAll com filtros dinâmicos, getById, create, update, delete, massDelete, search

Repository: onde a busca genérica realmente acontece

O BaseRepository recebe o Model no construtor e expõe um getAll que aceita uma lista de filtros já normalizada — cada filtro é um trio [campo, operador, valor] — junto com paginação e ordenação. Isso significa que buscar candidato por nome, vaga por status ou qualquer outra combinação passa pelo mesmo método, sem precisar de um método novo por campo:


public function getAll(array $filters = [], int $perPage = 5, string $sortField = 'id', string $sortDirection = 'asc')
{
    $query = $this->model->query();

    foreach ($filters as $key => $value) {
        $query->where($filters[$key][0], $filters[$key][1], $filters[$key][2]);
    }

    return $query->orderBy($sortField, $sortDirection)->paginate($perPage);
}


Cada repositório concreto (CandidatoRepository, VagaRepository, VagaCandidatoRepository) só existe para injetar o Model correspondente no parent::__construct. Nenhum deles sobrescreve getAll, create ou delete — a única razão de existirem como classes separadas é o type hint do Model no construtor.

Service: quem monta o filtro e quem decide o que é erro

O BaseService é a camada que entende a sintaxe da busca vinda da URL. A ideia é permitir uma query como ?filters[]=nome|like&nome=sr — o parâmetro repetível filters[] diz quais campos entram no filtro e com qual operador, e o próprio payload da requisição carrega o valor de cada campo:

Parâmetro Papel na busca filters[] Campo e operador, no formato campo|operador. Pode repetir para combinar vários filtros per_page Registros por página (padrão: 20) sort_field Campo de ordenação (padrão: id) sort_direction asc ou desc (padrão: asc)

Fora da busca, o BaseService também padroniza o tratamento de erro: toda chamada ao repositório fica dentro de um try/catch que loga a exceção original e relança uma ServiceException com uma mensagem de negócio e o status HTTP certo — 404 quando o registro não existe, 500 para o resto. O Controller nunca precisa saber o que deu errado internamente, só devolve o que o Service decidiu.

Controller: um fluxo fixo de request → validação → service → resposta

O BaseController guarda o Service e o Request certos através de setService e setRequest — e é o próprio setRequest quem já dispara a validação, chamando $this->validate($request, $request->rules()) antes de qualquer coisa. Cada Controller concreto faz basicamente isto no construtor:

class CandidatoController extends BaseController
{
    public function __construct(CandidatoRequest $request, CandidatoService $service)
    {
        $this->setRequest($request);
        $this->setService($service);
    }
}

É só isso. O index, show, store, update, destroy e um massDestroy (exclusão em lote por lista de IDs) já vêm prontos da base, todos devolvendo JSON com o código HTTP adequado — 201 na criação, 204 na exclusão, 404 quando não encontra. As rotas em si são uma linha por recurso, usando Route::apiResource.

Request: regras de validação que mudam com o verbo HTTP

O BaseRequest resolve um detalhe que normalmente vira duplicação: a mesma entidade costuma ter regras de validação diferentes para criar (POST) e atualizar (PUT), e às vezes nenhuma regra para GET ou DELETE. Em vez de sobrescrever o método rules() em cada Request, a classe base já decide qual conjunto usar de acordo com o método da requisição, e cada Request concreto só declara os arrays get_rules, post_rules, put_rules e delete_rules — além de um format_attributes para os nomes dos campos aparecerem certos nas mensagens de erro.

O ganho prático

O teste real dessa abstração é o quanto sobra para escrever quando chega uma entidade nova. Cada CRUD completo — Controller, Service, Repository e Request — some em algo entre 10 e 30 linhas de código específico, porque tudo que é genérico (paginação, ordenação, filtro dinâmico, validação por verbo, tratamento de erro, resposta JSON) já está resolvido uma única vez nas classes base. O vínculo entre Candidato e Vaga, por exemplo, ganhou o CRUD inteiro só reaproveitando a mesma estrutura, mesmo sendo uma tabela pivot com relação N:N.

Isso não é o desenho que eu usaria hoje sem ressalvas — repository genérico em cima do Eloquent tem limites conhecidos quando a regra de negócio cresce, e a busca por filters[] abre mão de uma tipagem mais rígida na query. Mas como exercício de reduzir boilerplate e manter CRUDs consistentes entre si, o padrão cumpriu bem o que se propôs, e virou uma referência que revisito quando penso em como estruturar a próxima API.

Repositório

Código completo em github.com/paulodm145/candidatos-vagas.