Laravel 12.51: 3 Funcionalidades Que Mudaram Minha Forma de Desenvolver
Quatro segundos. Era o tempo que uma única página de administração levava para carregar em um projeto de cliente que eu tocava no ano passado.
O culpado não era um índice faltando. Não era uma consulta N+1. Era uma chamada firstOrCreate — código Laravel completamente normal, sem nada de especial — onde o array de atributos de criação incluía uma chamada API para um serviço de cobrança terceirizado e uma operação de hash. Ambas eram avaliadas em cada requisição. Mesmo quando o usuário já existia. Mesmo quando nada estava sendo criado.
Encontrei o bug depois de quase uma hora de chamadas dd() e rolagem lenta pela timeline do Laravel Debugbar. Quando finalmente caiu a ficha, senti aquela mistura específica de alívio e vergonha que só aparece quando você percebe que o bug era invisível não porque era engenhoso — mas porque você não entendia como a avaliação de argumentos do PHP realmente funciona.
A correção que escrevi na época foi um workaround: verificar se o registro existia primeiro, depois chamar o método apropriado com base no resultado. Funcionava. Era deselegante. E toda vez que eu encontrava esse padrão em outros projetos, pensava "tem que ter um jeito mais limpo."
O Laravel 12.51 entrega esse jeito mais limpo nativamente. E isso é apenas uma das três novidades nessa release que me fizeram pausar o que eu estava fazendo e ler o changelog duas vezes.
As outras duas — um método nativo de timeout para consultas e callbacks encadeáveis no validador — resolvem dores que venho contornando com código personalizado e gambiarras há anos. Uma delas é sobre performance que degrada silenciosamente aplicações em produção. A outra é sobre qualidade de código que degrada silenciosamente equipes. Ambas importam.
Aqui está o que realmente mudou, como usar, e — porque a maioria dos changelogs pula essa parte — quando cada funcionalidade vale o seu tempo e quando não vale.
O Problema Que Estava Escondido nas Suas Chamadas firstOrCreate
Antes de explicar a correção, você precisa entender por que o bug existe. Porque se você é como a maioria dos desenvolvedores Laravel, vem escrevendo esse código há anos e ele nunca quebrou nada de forma óbvia.
A questão central: PHP avalia todos os argumentos de uma função antes de executá-la.
Essa frase parece simples. As implicações não são.
Quando você escreve isso:
$user = User::firstOrCreate(
['email' => $email],
[
'name' => $name,
'avatar' => $this->avatarService->generate($email), // API call
'api_key' => $this->keyService->issue($email), // another API call
'hash' => bcrypt(Str::random(64)), // CPU-bound
]
);
O PHP monta o segundo array inteiro — chamando generate(), issue() e bcrypt() — antes mesmo do firstOrCreate começar. Então o firstOrCreate executa sua consulta. Se o usuário existe, ele retorna esse usuário e descarta o array computado completamente. Todo o processamento que você acabou de rodar? Desperdiçado.
Em desenvolvimento, isso não dói. Bancos de dados de teste têm poucos registros. Chamadas API atingem sandboxes que respondem em milissegundos. Você roda o código, funciona, faz deploy.
Em produção, isso se torna um imposto que você paga em cada requisição que toca um registro existente — que, em uma aplicação madura, é quase toda requisição. Se o seu serviço de avatar leva 300ms e o serviço de chaves leva 200ms, você está adicionando 500ms de puro desperdício a essas requisições. Para sempre. Até que alguém rastreie o endpoint lento e descubra o porquê.
A correção que o Laravel 12.51 introduz é limpa: passe o segundo argumento como um closure.
$user = User::firstOrCreate(
['email' => $email],
function () use ($name, $email) {
return [
'name' => $name,
'avatar' => $this->avatarService->generate($email),
'api_key' => $this->keyService->issue($email),
'hash' => bcrypt(Str::random(64)),
];
}
);
A implementação do Laravel 12.51 verifica se o segundo argumento é um callable. Se for, o closure só executa quando o registro não existe. Registros existentes são retornados imediatamente. O trabalho de criação nunca é executado.
Isso é avaliação preguiçosa (lazy evaluation) — um padrão que está na caixa de ferramentas do PHP há anos através de closures e generators, mas que não estava disponível nos métodos de criação de registros do query builder até agora.
O método createOrFirst recebe o mesmo tratamento. Esse método funciona na ordem inversa: tenta inserir primeiro e faz fallback para um select em caso de violação de restrição única. A mesma lógica de avaliação preguiçosa se aplica. Passe um closure, e os atributos só são computados se uma inserção for realmente necessária.
Quando isso realmente importa para o seu código
Para ser claro: se seus atributos de criação são valores estáticos — strings, inteiros, flags booleanos — você não precisa do closure. O PHP avalia esses em microssegundos. A otimização faz sentido quando a computação é cara. Pergunte-se: alguma coisa naquele segundo array faz uma chamada de rede, executa uma operação criptográfica, consulta o banco de dados, ou executa código que leva mais do que alguns milissegundos?
Se sim, envolva. Toda chamada firstOrCreate e createOrFirst com lógica de criação cara é candidata.
Encontre-as no seu projeto agora:
grep -rn "firstOrCreate\|createOrFirst" app/ --include="*.php"
Abra cada arquivo. Olhe o segundo argumento. Se está fazendo trabalho real, você está pagando o imposto da avaliação antecipada.
Os números dos meus próprios testes — usando um sleep artificial de dois segundos para simular duas chamadas API — foram gritantes: a busca de registros existentes caiu de 2.003ms para 8ms. A criação de novos registros ficou em 2.003ms porque o trabalho é realmente necessário. Esse é o comportamento que você quer. Computar apenas quando precisa.
Mas aqui é onde as coisas ficam interessantes, e onde a maioria dos posts sobre essa funcionalidade para antes da parte importante.
Matando Consultas Longas Antes Que Elas Matem Seu Banco de Dados
A segunda funcionalidade é operacionalmente diferente da avaliação preguiçosa. Avaliação preguiçosa é sobre evitar trabalho desnecessário. Timeout de consultas é sobre limitar trabalho necessário que saiu do controle.
Se você já trabalhou com Laravel em tabelas MySQL grandes — estou falando de centenas de milhares a milhões de linhas — provavelmente já viu esse padrão: uma consulta que funcionava perfeitamente seis meses atrás agora leva 30, 40, 60 segundos para completar. A tabela cresceu. Nenhum índice novo foi adicionado. Um relatório analítico que rodava em menos de um segundo agora segura uma conexão de banco de dados aberta tempo suficiente para múltiplas requisições HTTP expirarem.
O MySQL tem uma solução para isso há anos: o hint de otimização MAX_EXECUTION_TIME. Você pode embutir diretamente em uma instrução SELECT para definir um teto em milissegundos para a duração da consulta. Se a consulta exceder esse teto, o MySQL a encerra e retorna um erro ao invés de deixá-la rodar indefinidamente.
O problema era que usar isso no Laravel exigia sair do query builder:
// Option 1: Raw SQL. Breaks the fluent interface.
$results = DB::select('SELECT /*+ MAX_EXECUTION_TIME(5000) */ * FROM orders WHERE status = "pending"');
// Option 2: Session-level setting. Affects more than just this query.
DB::statement('SET SESSION MAX_EXECUTION_TIME = 5000');
$orders = Order::where('status', 'pending')->get();
DB::statement('SET SESSION MAX_EXECUTION_TIME = 0'); // must reset or everything is capped
// Option 3: Custom macro. Works, but requires maintenance and project-specific setup.
Builder::macro('maxExecutionTime', function (int $ms) {
return $this->beforeQuery(function ($q) use ($ms) {
$q->addSelectExpression("/*+ MAX_EXECUTION_TIME($ms) */");
});
});
Eu estava usando a Opção 3 em vários projetos. Funciona, mas você carrega código personalizado que novos membros da equipe não conhecem, que não aparece no autocomplete da IDE, e que você precisa lembrar de adicionar a cada novo projeto Laravel que começa.
O Laravel 12.51 entrega isso nativamente:
$orders = Order::where('status', 'pending')
->timeout(5)
->get();
O método timeout() aceita segundos (não milissegundos — note a diferença do hint raw do MySQL, que usa milissegundos). Por baixo dos panos, o Laravel converte e injeta o hint de otimização na consulta. Atingiu o limite e você recebe uma QueryException com a mensagem de consulta interrompida do MySQL.
Aqui está o padrão pronto para produção para envolver consultas com timeout:
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Log;
public function generateSalesReport(array $filters): array
{
try {
$results = Order::query()
->where('created_at', '>=', $filters['start_date'])
->where('created_at', '<=', $filters['end_date'])
->with(['items', 'customer', 'discounts'])
->when($filters['status'] ?? null, fn($q, $s) => $q->where('status', $s))
->timeout(15)
->get();
return $this->formatReport($results);
} catch (QueryException $e) {
if (str_contains($e->getMessage(), 'Query execution was interrupted')) {
Log::warning('Sales report query timed out', [
'filters' => $filters,
'timeout_seconds' => 15,
]);
// Graceful degradation: return cached result or a reduced dataset
return $this->getCachedReport($filters) ?? [];
}
throw $e; // Re-throw unexpected database errors
}
}
Esse padrão — timeout mais tratamento explícito de exceções — é o que separa "adicionei timeout" de "tratei timeout corretamente." O timeout sozinho apenas substitui um travamento infinito por uma exceção. O tratamento da exceção é onde você realmente protege seus usuários.
A parte que a maioria dos tutoriais pula: isso é exclusivo do MySQL
O comportamento do método timeout() depende do driver. Para MySQL e MariaDB, ele usa o hint de otimização MAX_EXECUTION_TIME. Para PostgreSQL, a implementação é diferente — o PostgreSQL usa timeouts a nível de instrução configurados de forma diferente, e o comportamento entre drivers pode não ser o que você espera.
Se você está construindo um SaaS multi-tenant onde diferentes clientes podem estar em diferentes drivers de banco de dados, ou se desenvolve em SQLite localmente mas faz deploy em MySQL em produção (uma configuração surpreendentemente comum), teste o comportamento do timeout no driver real de produção antes de depender dele.
Também vale saber: MAX_EXECUTION_TIME se aplica a instruções SELECT no MySQL. Não se aplica a operações INSERT, UPDATE ou DELETE — essas requerem técnicas diferentes para timeout. O método timeout() no Laravel é para consultas de leitura.
Use nos seus endpoints de relatórios. Use dentro de queue jobs que fazem processamento pesado de banco de dados. Use em qualquer lugar onde uma consulta possa legitimamente precisar rodar por um tempo, mas nunca deveria rodar para sempre.
A essa altura você já tem avaliação preguiçosa e timeouts de consulta aplicados. A terceira mudança no 12.51 é diferente em natureza — menos sobre performance bruta, mais sobre o tipo de qualidade de código que se acumula em toda uma equipe ao longo do tempo.
Callbacks do Validator: Fazendo a Validação Manual Parecer Laravel de Novo
A validação de requisições HTTP no Laravel é excelente. Você define uma classe Form Request, injeta no seu controller, e o framework cuida de tudo automaticamente. Validação passa, o controller executa. Validação falha, o usuário recebe os erros de volta. Limpo, automático, zero código repetitivo.
Mas nem toda validação acontece em requisições HTTP.
Comandos Artisan precisam validar argumentos. Classes de serviço precisam validar entrada de queue jobs. Classes de domínio precisam validar dados vindos de API externas. Em todos esses cenários, você está fazendo validação manual — criando uma instância de Validator na mão e verificando o resultado por conta própria.
O padrão clássico:
public function handle(): int
{
$validator = Validator::make($this->arguments(), [
'email' => 'required|email|max:255',
'role' => 'required|in:admin,editor,viewer',
]);
if ($validator->fails()) {
$this->error('Validation failed:');
foreach ($validator->errors()->all() as $error) {
$this->line(" — {$error}");
}
return self::FAILURE;
}
$this->info('Creating user...');
$this->userService->create($validator->validated());
return self::SUCCESS;
}
Esse código está correto. Mas leia de novo e perceba o ritmo: você constrói o validador, depois quebra o fluxo para verificar fails(), tratar o caminho de erro, e só então — só depois — segue com o caminho de sucesso. Dois fluxos de controle separados para uma única operação de validação.
O Laravel 12.51 adiciona whenFails() e whenPasses() diretamente na instância do Validator:
public function handle(): int
{
return Validator::make($this->arguments(), [
'email' => 'required|email|max:255',
'role' => 'required|in:admin,editor,viewer',
])
->whenFails(function (Validator $validator) {
$this->error('Validation failed:');
foreach ($validator->errors()->all() as $error) {
$this->line(" — {$error}");
}
return self::FAILURE;
})
->whenPasses(function (Validator $validator) {
$this->info('Creating user...');
$this->userService->create($validator->validated());
return self::SUCCESS;
});
}
Ambos callbacks recebem a instância do validador como parâmetro. O valor de retorno do callback que executar se torna o valor de retorno da cadeia. Se a validação falha, whenFails roda e seu valor de retorno é retornado. Se a validação passa, whenPasses roda.
Você não precisa usar ambos. Em uma classe de serviço que deveria lançar exceção em caso de falha:
public function processWebhook(array $payload): void
{
Validator::make($payload, [
'event' => 'required|string',
'data' => 'required|array',
'user_id' => 'required|integer|exists:users,id',
])
->whenFails(fn($v) => throw new InvalidPayloadException($v->errors()->toJson()))
->whenPasses(function ($v) {
$this->dispatchWebhookEvent($v->validated());
});
}
Ou você pode usar apenas whenFails para tratar o caso de erro e deixar a execução continuar naturalmente em caso de sucesso:
Validator::make($data, $rules)
->whenFails(function ($validator) {
Log::error('Data validation failed', ['errors' => $validator->errors()->toArray()]);
return false;
});
// code here runs whether validation passed or failed (if whenFails didn't return/throw)
A API é flexível o suficiente para cobrir a maioria dos cenários do mundo real sem forçar você a um padrão específico.
Aplicando as Três Funcionalidades: Um Exemplo Realista
Deixe eu mostrar como essas três funcionalidades ficam juntas em um trecho real de código — um comando que importa usuários de um arquivo CSV.
Antes do 12.51:
<?php
namespace App\Console\Commands;
use App\Models\User;
use App\Services\BillingService;
use App\Services\AvatarService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
class ImportUsers extends Command
{
protected $signature = 'users:import {file}';
public function handle(BillingService $billing, AvatarService $avatars): int
{
$validator = Validator::make(['file' => $this->argument('file')], [
'file' => 'required|string',
]);
if ($validator->fails()) {
$this->error($validator->errors()->first());
return self::FAILURE;
}
$rows = array_map('str_getcsv', file($this->argument('file')));
foreach ($rows as $row) {
[$email, $name, $plan] = $row;
// Eager evaluation: avatar and plan are computed even for existing users
$user = User::firstOrCreate(
['email' => $email],
[
'name' => $name,
'avatar' => $avatars->generate($email), // API call, always runs
'plan_id' => $billing->getDefaultPlan()->id, // DB query, always runs
]
);
// Long-running query with no timeout
$exists = DB::table('user_activities')
->where('user_id', $user->id)
->where('created_at', '>=', now()->subYear())
->count();
$this->line("Processed: {$email} (activities: {$exists})");
}
return self::SUCCESS;
}
}
Depois do 12.51:
<?php
namespace App\Console\Commands;
use App\Models\User;
use App\Services\BillingService;
use App\Services\AvatarService;
use Illuminate\Console\Command;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
class ImportUsers extends Command
{
protected $signature = 'users:import {file}';
public function handle(BillingService $billing, AvatarService $avatars): int
{
return Validator::make(['file' => $this->argument('file')], [
'file' => 'required|string',
])
->whenFails(function ($v) {
$this->error($v->errors()->first());
return self::FAILURE;
})
->whenPasses(function () use ($billing, $avatars) {
$rows = array_map('str_getcsv', file($this->argument('file')));
foreach ($rows as $row) {
[$email, $name] = $row;
// Lazy evaluation: closure only runs if user doesn't exist
$user = User::firstOrCreate(
['email' => $email],
function () use ($email, $name, $billing, $avatars) {
return [
'name' => $name,
'avatar' => $avatars->generate($email),
'plan_id' => $billing->getDefaultPlan()->id,
];
}
);
// Timeout: protect against long-running activity queries
try {
$activityCount = DB::table('user_activities')
->where('user_id', $user->id)
->where('created_at', '>=', now()->subYear())
->timeout(3)
->count();
$this->line("Processed: {$email} (activities: {$activityCount})");
} catch (QueryException $e) {
if (str_contains($e->getMessage(), 'Query execution was interrupted')) {
Log::warning("Activity query timed out for user {$email}");
$this->line("Processed: {$email} (activity count unavailable)");
continue;
}
throw $e;
}
}
return self::SUCCESS;
});
}
}
A versão posterior é ligeiramente mais longa porque trata os casos de falha explicitamente — mas a intenção de cada seção é mais clara. A lógica de validação vive em um bloco encadeado. A lógica de criação está separada da lógica de busca. Consultas longas têm limites.
É assim que a atualização para o 12.51 deveria realmente parecer na prática: melhorias pontuais, não reescritas.
O Que Eu Realmente Penso Sobre Essas Mudanças
Certo — vamos ser diretos, porque a maioria dos posts não diz isso.
A funcionalidade de avaliação preguiçosa aborda uma escolha de design que eu acho que foi errada desde o início. "Passar atributos de criação como array" é a API intuitiva, e é o que todo tutorial e exemplo da documentação mostra. Mas silenciosamente prepara os desenvolvedores para uma armadilha de performance. A suposição que a maioria faz é que o PHP é esperto o suficiente para não fazer trabalho desnecessário. Essa suposição é errada para argumentos de funções.
Fico feliz que isso foi corrigido. Mas também acho que vale a pena ser honesto: essa é uma classe de bug que o Laravel poderia ter tornado impossível anos atrás ao documentar o comportamento de avaliação antecipada de forma mais proeminente na documentação do firstOrCreate. Já vi exatamente esse bug em três codebases de clientes diferentes. Provavelmente tenho em alguns dos meus próprios projetos que ainda não cresceram o suficiente para sentir a dor.
Verifique seu código.
A funcionalidade de timeout para consultas é excelente e vou usar imediatamente — mas com uma ressalva que eu faria questão de apontar: o nome do método timeout() não dá nenhuma indicação de que é específico do MySQL. Se você trabalha em uma equipe onde nem todos conhecem os internos, alguém vai adicionar timeout() a uma consulta PostgreSQL esperando o comportamento do MySQL e vai ficar confuso quando as coisas não funcionarem como esperado. Melhor documentação e possivelmente um aviso específico do driver na exceção seriam bem-vindos.
Os métodos whenFails e whenPasses são legais. Gosto deles. Mas já vi pessoas online usarem em contextos onde tornam o código menos claro, não mais — particularmente quando a lógica do callback é complexa o suficiente para que um if/else simples teria sido mais legível. Esses métodos brilham para tratamento de validação curto e focado: lançar uma exceção em caso de falha, despachar um job em caso de sucesso. Se seus callbacks têm 15 linhas cada, pergunte-se se um bloco if tradicional não seria realmente mais limpo.
O modelo de releases incrementais do Laravel faz com que melhorias de "versão menor" como essas tendam a passar despercebidas. Isso é um erro. Só a correção de avaliação preguiçosa no firstOrCreate poderia melhorar os tempos de resposta em segundos para aplicações que convivem com esse problema sem saber. Isso não é menor na prática — é uma vitória significativa disfarçada de entrada entediante no changelog.
A Matemática da Performance: O Que Você Pode Realmente Esperar
Deixe eu ser específico sobre o que essas mudanças vão e não vão fazer pelas métricas da sua aplicação.
Lazy firstOrCreate — os números:
A melhoria depende inteiramente do que está no seu closure de criação. Aqui está um framework aproximado:
| Custo do Atributo de Criação | Requisições Tocando Registros Existentes | Economia Esperada por Requisição |
|---|---|---|
| Apenas valores estáticos | Qualquer | ~0ms (não se preocupe com closure) |
| Um único bcrypt / hash | 80%+ | 50–200ms |
| Uma chamada API externa | 80%+ | 200–1.500ms |
| Duas chamadas API + consulta DB | 80%+ | 500–3.000ms |
Se 80% das suas requisições tocam registros existentes (típico para sessões de usuários logados), e seus atributos de criação incluem duas chamadas API com média de 500ms cada, você está olhando para uma redução de 1.000ms por requisição no percentil 80. Para endpoints de alto tráfego, isso é transformador.
Para o projeto do cliente que mencionei no início deste post — aquele com as páginas de admin de quatro segundos — a melhoria medida após mudar para atributos de criação baseados em closure foi de 3,8 segundos por requisição no nível p95. Isso não é teórico. É uma aplicação que foi de lenta e frustrante para responsiva em um único commit.
Query timeout — o cenário operacional:
O timeout não torna consultas mais rápidas. Ele torna o modo de falha controlado ao invés de ilimitado. O valor está no seu budget de erros e na confiabilidade do sistema:
- Antes do timeout: consulta de longa duração segura conexões do banco → pool de conexões esgota → outras consultas enfileiram → falha em cascata
- Depois do timeout: consulta de longa duração atinge o teto → QueryException → você trata com elegância → outras consultas prosseguem normalmente
Para uma aplicação em produção lidando com tráfego real, essa diferença é o limite entre uma página lenta e uma queda total.
Callbacks encadeáveis do Validator — o ângulo da experiência do desenvolvedor:
Sem impacto na performance em tempo de execução. O ROI aqui é medido em tempo de code review, velocidade de onboarding e custo de manutenção ao longo de meses e anos. Equipes que escrevem código mais limpo cometem menos erros. Menos erros significam menos incidentes. O efeito composto é real mesmo que não seja mensurável com um cronômetro.
Atualize e Use Essas Funcionalidades de Verdade
Aqui está o caminho prático.
Atualize primeiro. Se você está no Laravel 12.x, é uma atualização via Composer:
composer require laravel/framework:^12.51
Rode sua suite de testes. Essas são mudanças retrocompatíveis — se seus testes passarem, está tudo certo.
Em seguida, audite suas chamadas firstOrCreate e createOrFirst. Rode o grep que mencionei antes:
grep -rn "firstOrCreate\|createOrFirst" app/ --include="*.php"
Para cada resultado, verifique o segundo argumento. Se faz trabalho real, converta para um closure e meça o antes/depois no seu ambiente de staging.
Depois encontre suas consultas mais pesadas — endpoints analíticos, rotas de relatórios, queue jobs que fazem agregações complexas. Adicione ->timeout() com um teto razoável. Envolva em try/catch com degradação elegante. Faça deploy no staging, teste o caminho do timeout explicitamente (você pode temporariamente reduzir o timeout para dispará-lo), e confirme que seu tratamento de erros funciona como esperado.
Por fim, pegue um comando Artisan ou classe de serviço que use validação manual e refatore para usar whenFails/whenPasses. Veja como fica a leitura. Se ficou mais limpo, aplique o padrão em outros lugares. Se sua equipe prefere a estrutura tradicional de if/else, tudo bem também — use a ferramenta que torna seu código mais claro.
Eu mantenho uma lista de funcionalidades que queria ter nativamente no Laravel há um tempo. Lazy firstOrCreate estava nessa lista. Query timeout estava nessa lista. E toda vez que uma release entrega algo dessa lista — sem quebrar nada — é um bom lembrete de que o framework está sendo construído por pessoas que realmente o usam em produção.
Aquela carga de página de quatro segundos no painel de admin do cliente? Agora são 90ms. Mesmo banco de dados. Mesma infraestrutura. Uma única mudança em como os atributos de criação são passados para uma única chamada de método.
Vá verificar seu código.
🤝 Vamos Trabalhar Juntos
Procurando construir sistemas de IA, automatizar fluxos de trabalho ou escalar sua infraestrutura tecnológica? Adoraria ajudar.
- 🔗 Fiverr (builds e integrações sob medida): fiverr.com/s/EgxYmWD
- 🌐 Portfolio: mejba.me
- 🏢 Ramlit Limited (soluções empresariais): ramlit.com
- 🎨 ColorPark (design e branding): colorpark.io
- 🛡 xCyberSecurity (serviços de segurança): xcybersecurity.io