OCR de placa em PHP e Laravel: integrar leitura de placa em 2026
Tutorial completo para reconhecer placa de carro em PHP puro e Laravel. Comparativo entre Tesseract OCR, gerador próprio e API. Código pronto e custo real em produção.
PHP ainda é a linguagem dominante em backend BR de pequenas e médias empresas — sistema de estacionamento, controle de acesso de condomínio, ERP de oficina, software de despachante. E quase todos esses casos, em algum momento, precisam ler placa de carro automaticamente.
O problema é que a maioria dos tutoriais de OCR em PHP que circula na internet tem 8 anos, ensina a chamar Tesseract via shell_exec, e ignora completamente o padrão Mercosul brasileiro. Esse post atualiza o caminho.
TL;DR
- Tesseract via
shell_exec: funciona, ~65% de precisão em placas brasileiras, infra de servidor complica deploy. - Pacote PHP de wrapper (
thiagoalessio/tesseract_ocr): mesma precisão, código mais limpo, ainda precisa Tesseract instalado no servidor. - API HTTP via Guzzle ou cURL: 95%+ de precisão, deploy trivial (zero dependência nativa), suporta Mercosul. Esse é o caminho que eu recomendo em 2026.
O cenário típico em PHP
Você tem um Laravel ou um WordPress headless, recebe upload de foto via formulário (<input type="file">), e precisa retornar a placa pra preencher um cadastro, validar entrada de cliente, ou registrar uma passagem.
// O que você quer fazer:
public function processarFoto(Request $request)
{
$foto = $request->file('foto');
$placa = LerPlaca::a($foto); // Quem é esse LerPlaca?
return response()->json(['placa' => $placa]);
}
A pergunta é: como implementar LerPlaca::a()?
Abordagem 1: Tesseract via shell_exec (PHP puro)
O Tesseract é o OCR open-source mais conhecido. Roda como binário. Em PHP puro:
function lerPlacaTesseract(string $imagemPath): ?string
{
// Pré-processamento com ImageMagick (binário, via convert)
$tmpProcessed = sys_get_temp_dir() . '/' . uniqid() . '.png';
shell_exec("convert {$imagemPath} -colorspace Gray -normalize -threshold 50% {$tmpProcessed}");
// OCR com whitelist de caracteres
$output = shell_exec(
"tesseract {$tmpProcessed} - -l eng -c tessedit_char_whitelist=ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 2>/dev/null"
);
@unlink($tmpProcessed);
if (!$output) return null;
// Extrai 7 caracteres plausíveis (padrão Mercosul ou antigo)
$clean = preg_replace('/\s+/', '', strtoupper($output));
if (preg_match('/[A-Z]{3}\d[A-Z0-9]\d{2}/', $clean, $m)) {
return $m[0];
}
return null;
}
Problema 1: shell_exec é vetor clássico de injection. Você precisa escapar todo path de usuário ou usar escapeshellarg().
Problema 2: seu servidor precisa ter tesseract, imagemagick, e os language data packs instalados. No shared hosting, geralmente impossível. No VPS, é gerenciar.
Problema 3: ~65% de precisão em placas brasileiras reais. Tesseract foi treinado em texto impresso de livro, não em placas refletivas com ângulo.
Abordagem 2: pacote thiagoalessio/tesseract_ocr
Mesmo Tesseract, sintaxe mais limpa:
composer require thiagoalessio/tesseract_ocr
use thiagoalessio\TesseractOCR\TesseractOCR;
function lerPlacaWrapper(string $imagemPath): ?string
{
$text = (new TesseractOCR($imagemPath))
->lang('eng')
->allowlist(range('A', 'Z'), range(0, 9))
->run();
$clean = preg_replace('/\s+/', '', strtoupper($text));
return preg_match('/[A-Z]{3}\d[A-Z0-9]\d{2}/', $clean, $m) ? $m[0] : null;
}
Mais elegante, mesma precisão, mesmas dependências de servidor.
Abordagem 3: API gerenciada via Guzzle
A solução que eu recomendo pra 95% dos casos: chama uma API HTTP, recebe JSON, pronto. Zero dependência nativa, zero tesseract no servidor.
Em PHP puro com cURL
function lerPlacaAPI(string $imagemPath): array
{
$apiKey = getenv('LDP_API_KEY');
$base64 = base64_encode(file_get_contents($imagemPath));
$ch = curl_init('https://leituradeplaca.com.br/api/v1/read-plate');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer {$apiKey}",
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode(['image_base64' => $base64]),
CURLOPT_TIMEOUT => 30,
]);
$response = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200) {
throw new \RuntimeException("API error {$code}: {$response}");
}
return json_decode($response, true);
}
// Uso
$result = lerPlacaAPI('/tmp/upload.jpg');
echo $result['plate']; // → "INO1102"
No Laravel, com Guzzle e config
config/services.php:
'leituradeplaca' => [
'key' => env('LDP_API_KEY'),
'url' => env('LDP_API_URL', 'https://leituradeplaca.com.br/api/v1'),
],
app/Services/LeitorPlacaService.php:
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\UploadedFile;
class LeitorPlacaService
{
public function lerDeUpload(UploadedFile $file): array
{
return Http::withToken(config('services.leituradeplaca.key'))
->timeout(30)
->retry(3, 200, throw: false)
->attach('image', file_get_contents($file->path()), $file->getClientOriginalName())
->post(config('services.leituradeplaca.url') . '/read-plate')
->throw()
->json();
}
}
Controller:
namespace App\Http\Controllers;
use App\Services\LeitorPlacaService;
use Illuminate\Http\Request;
class PlacaController extends Controller
{
public function __construct(private LeitorPlacaService $leitor) {}
public function processar(Request $request)
{
$request->validate([
'foto' => ['required', 'image', 'max:8192'],
]);
$resultado = $this->leitor->lerDeUpload($request->file('foto'));
return response()->json([
'placa' => $resultado['plate'],
'confianca' => $resultado['confidence'],
]);
}
}
Filas (recomendado em produção)
Como cada leitura demora 1-4 segundos, você não quer bloquear a request HTTP. Use uma Job Laravel:
namespace App\Jobs;
use App\Services\LeitorPlacaService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
class LerPlacaJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable;
public function __construct(public int $passagemId, public string $caminho) {}
public function handle(LeitorPlacaService $leitor): void
{
$resultado = $leitor->lerDoCaminho($this->caminho);
\App\Models\Passagem::where('id', $this->passagemId)
->update([
'placa' => $resultado['plate'],
'confianca' => $resultado['confidence'],
'lida_em' => now(),
]);
}
}
E no controller você responde imediatamente com 202 Accepted enquanto a fila processa.
Comparativo
| Critério | Tesseract shell_exec | Wrapper PHP | API gerenciada |
|---|---|---|---|
| Precisão (placa BR) | ~65% | ~65% | 95%+ |
| Mercosul out-of-the-box | Não | Não | Sim |
| Dependências de servidor | tesseract + imagemagick | tesseract | nenhuma |
| Roda em shared hosting | Não | Não | Sim |
| Latência | 1-3s | 1-3s | 1-4s |
| Custo R$/1000 leituras | 0 (CPU) | 0 (CPU) | R$ 6 a R$ 50 |
| Tempo de integração | 1 dia | 1-2h | 30 min |
Qual escolher
- Hobby / hackathon: Tesseract via wrapper, suficiente pra rodar local.
- Produção real (estacionamento, condomínio, oficina): API gerenciada. O custo unitário é desprezível pro volume típico desses negócios e o ganho de precisão paga sozinho.
- Volume gigante (>500k/mês) com infra própria: considere modelo treinado próprio, mas você provavelmente vai sair do PHP pra Python ou Node nesse caso.
Próximos passos
Crie sua conta em leituradeplaca.com.br/signup e teste a API com 100 leituras por R$ 4,99. Suporte e exemplos completos de PHP/Laravel em /docs.
Pronto para integrar?
Crie uma conta e ganhe acesso à API em menos de 60 segundos.