Existe uma cena que se repete em quase toda agência. Chega um pedido pequeno do cliente, do tipo "preciso de um endpoint que receba a notificação de pagamento do gateway e atualize o status do pedido no banco". O dev abre o terminal, digita laravel new, instala uns 50 pacotes via Composer e sobe um projeto de 300 megabytes pra resolver 60 linhas de lógica. Funciona, claro. Mas alguma coisa parece desproporcional ali.
Tem também a cena oposta. O iniciante que ouviu por aí que "PHP puro é ruim" e foge dele a qualquer custo. Subiu Laravel pra um formulário de contato. Subiu WordPress pra uma landing page. Subiu uma stack inteira porque "PHP sem framework é coisa de programador desatualizado". Spoiler: PHP não morreu, e usar a ferramenta certa pro tamanho do problema sempre foi parte do trabalho do dev.
Esse artigo é sobre o meio termo. PHP sem framework em 2026 é uma escolha técnica legítima em vários cenários, mas é uma escolha que carrega responsabilidades. O Laravel resolve várias coisas por você de forma silenciosa. Quando você dispensa o Laravel, essas mesmas coisas continuam sendo necessárias, só que agora você é o responsável.
A gente vai dividir a conversa em duas metades:
Primeira metade, arquitetural. O que o Laravel realmente entrega, onde esse pacote vale o preço, onde ele pesa mais do que ajuda, e como decidir entre os dois sem achismo.
Segunda metade, prática. Quatro cenários completos com código real: um webhook recebendo notificação do Mercado Pago, uma API REST mínima conversando com PostgreSQL, um script de linha de comando que importa CSV, e um formulário de contato com proteção contra ataques e envio via SMTP. Depois disso, uma seção dedicada a tudo que o framework cuidava por você em silêncio, e como cuidar disso direito sem ele.
O artigo é longo de propósito. PHP sem framework não cabe em três parágrafos. Bora.
1. O que o Laravel realmente te entrega
Antes de defender PHP puro, vale ser honesto sobre o que o Laravel coloca na mesa. Sem framework, várias coisas que parecem automáticas passam a ser sua responsabilidade. Conhecer esse catálogo é o primeiro passo pra decidir se a troca compensa.
Quando você roda laravel new e abre o projeto pela primeira vez, ganha de cara as seguintes peças:
Roteamento. Associar uma URL a uma função com uma linha de código. Sem isso, você precisa ler manualmente o
$_SERVER['REQUEST_URI']e decidir o que fazer com base nele.Eloquent. O ORM do Laravel. Permite trabalhar com tabelas do banco como se fossem objetos PHP. Sem isso, você escreve SQL e usa o PDO direto.
Validação. Conjunto de regras pra checar se um formulário ou requisição veio com os dados esperados. Sem isso, é
if,elseefilter_varespalhados pelo código.Autenticação. Login, registro, recuperação de senha, lembrar-me. Sem isso, você implementa o fluxo do zero, incluindo o hash da senha e o gerenciamento da sessão de login.
Sessões e proteção CSRF. Cookies seguros e proteção contra um tipo de ataque chamado CSRF (Cross-Site Request Forgery) já vêm configurados. Sem framework, você cuida de tudo isso manualmente.
Blade. Sistema de templates pra misturar HTML com PHP de forma limpa, com layouts e componentes reutilizáveis. Sem isso, você usa PHP puro dentro de arquivos
.php, ou monta um sistema próprio.Filas e jobs. Forma de processar trabalho pesado em segundo plano, em vez de fazer o usuário esperar. Sem framework, você precisa de uma infraestrutura própria (Redis, worker, controle de tentativas) ou de pacotes avulsos como o Symfony Messenger.
Envio de e-mail. Classe pronta pra mandar e-mails via SMTP, com retry, formatação HTML e templates. Sem isso, você usa pacotes como PHPMailer ou Symfony Mailer direto (vamos fazer exatamente isso na segunda metade).
Logging. Sistema de log configurado, com níveis (debug, info, warning, error) e arquivos rotativos. Sem isso, você implementa do zero ou usa Monolog direto.
Contêiner de serviços. Maneira organizada de criar e injetar dependências entre classes. Sem isso, você instancia classes manualmente, o que funciona bem em projetos pequenos.
Middleware. Camadas que rodam antes ou depois de cada requisição, tipo "verificar se o usuário está logado". Sem framework, vira função chamada no começo de cada endpoint.
Migrations. Forma de versionar mudanças no banco usando código, em vez de executar SQL na mão. Sem framework, você usa um pacote dedicado (Phinx é o mais comum) ou faz manualmente.
Defaults de segurança. Headers HTTP de segurança, escape automático no Blade, token CSRF inserido em formulários sem você pedir. Sem framework, cada um desses precisa ser feito por você.
Ecossistema. Pacotes oficiais ou amplamente adotados pra praticamente qualquer necessidade (Sanctum pra autenticação de API, Cashier pra integração com Stripe, Horizon pra monitorar filas, Scout pra busca). Sem framework, você ainda tem pacotes, mas precisa montar a integração na mão.
É bastante coisa, e o ponto importante é: nada disso é magia. Tudo está disponível em pacotes avulsos no Composer. A diferença é que o Laravel já integrou tudo, configurou os defaults seguros e padronizou o jeito de usar. Você ganha consistência e velocidade inicial. Em troca, leva tudo isso na bagagem, mesmo quando só precisa de uma pequena parte.
2. Onde esse pacote vale o preço
A regra geral é simples: quanto mais peças desse catálogo você vai usar, melhor a relação custo-benefício do framework. Existem cenários típicos onde o Laravel se paga com folga:
Aplicações com muitas rotas e regras de negócio. Um sistema de gestão, um CRM, uma plataforma de cursos, um marketplace. Esses tipos de projeto facilmente passam de 100 rotas, com autenticação, perfis de usuário, validação complexa, e-mails transacionais, tarefas agendadas. Implementar tudo isso sem framework é mais código pra escrever, mais código pra revisar e mais código pra manter.
Equipes com mais de duas pessoas. Framework impõe um jeito padrão de organizar as coisas. Quando outro dev entra no projeto, ele já sabe onde fica o controller, onde fica o model, onde ficam as rotas. Sem framework, cada projeto vira uma surpresa, com uma estrutura inventada pelo dev original que ninguém mais entende.
Projetos que vão durar anos e crescer com o tempo. A estrutura do Laravel envelhece bem. O que você escreve hoje seguindo as convenções vai continuar legível e modificável daqui a três anos. Código PHP puro escrito sem disciplina costuma envelhecer mal.
Aplicações que precisam de auth completa, sistema de pagamento, controle de assinatura ou sistemas com múltiplos clientes na mesma aplicação. O ecossistema do Laravel resolve isso com pacotes oficiais ou amplamente adotados. Reimplementar billing com integração Stripe na mão, por exemplo, é trabalho de meses, e com bastante risco de bug em produção.
SaaS, e-commerce e dashboards administrativos. Esses três tipos de aplicação tendem a usar quase tudo do catálogo. Aqui o framework é claramente o caminho mais curto.
Repare no padrão. Todos esses casos compartilham pelo menos um destes três sinais: volume alto de funcionalidades, complexidade de domínio ou vida útil longa. Quando os três aparecem juntos, framework deixa de ser opção e vira a escolha óbvia.
3. Onde esse pacote pesa mais do que ajuda
O outro lado da moeda. Em vários cenários reais, instalar Laravel é resolver um problema de cinco minutos com uma ferramenta de cinco horas.
Um endpoint que recebe webhook e atualiza um status. Você precisa de uma rota, uma leitura no banco, uma resposta. Não precisa de dezenas de pacotes carregados em memória pra fazer isso.
Scripts CLI que rodam por agendamento. Importação de planilha, sincronização noturna, limpeza de tabela. Subir o Laravel inteiro só pra rodar um script que executa uma vez por mês é peso morto.
Integração entre duas APIs. Pega dado de um lugar, transforma, manda pro outro. Não tem domínio de negócio aqui. Framework não acrescenta nada de útil.
Páginas com pouco dinamismo. Uma landing page com um formulário de contato e talvez uma tela de obrigado. Subir Laravel pra isso é desperdício de host, de memória do servidor e do seu próprio tempo configurando.
Plugins e extensões de outros sistemas. Plugin de WordPress, módulo de Magento, extensão de PrestaShop. Todos esses são PHP sem framework disfarçado. Saber escrever PHP puro com qualidade te torna muito melhor nesse tipo de trabalho.
Hospedagem compartilhada barata. Aquele cliente pequeno que paga 15 reais por mês de hospedagem provavelmente não vai conseguir rodar Laravel direito (memória limitada, sem acesso a Composer, sem worker pra fila). PHP puro roda em qualquer cPanel.
Microsserviços minúsculos. Aquele serviço que faz uma coisa só, tipo gerar QR Code, validar CPF ou converter unidade. Quanto mais leve, melhor. Framework aqui é peso desnecessário.
Laboratório e aprendizado. Quando o objetivo é entender como uma coisa funciona, e não entregar um produto pronto. PHP puro te força a entender o que o framework escondia. Vale a pena passar por isso pelo menos uma vez na carreira.
O padrão aqui é o oposto do anterior: poucas funcionalidades, domínio simples ou vida útil curta. Framework nesses casos é uma alavanca pesada pra mover uma pedra leve. Você gasta mais energia segurando a alavanca do que moveria a pedra com a mão.
4. Como decidir entre os dois sem achismo
Em vez de seguir intuição ou seguir moda, vale passar a próxima decisão por uma lista curta de perguntas. Se a maioria das respostas pender pra um lado, o caminho fica claro.
Pergunta | PHP sem framework | Laravel |
|---|---|---|
Quantas rotas o projeto vai ter? | Até cerca de 10 | Mais que isso |
Por quanto tempo vai ser mantido? | Menos de um ano, ou com pouca evolução prevista | Anos, com evolução constante |
Quantas pessoas vão trabalhar nele? | Você sozinho, ou no máximo duas | Três ou mais |
Precisa de autenticação completa (login, registro, recuperação)? | Não, ou só uma proteção simples | Sim, com fluxo completo |
Precisa de filas, jobs agendados ou e-mail em massa? | Não, ou só uma tarefa pontual | Sim, várias delas |
Onde o projeto vai rodar? | Hospedagem compartilhada ou ambiente simples | VPS, container, ambiente em nuvem |
Quanto tempo você tem pra configurar? | Pouco, precisa estar no ar hoje | Tem espaço pra configurar direito |
Se cinco ou mais respostas pendem pra PHP sem framework, vai de PHP sem framework com tranquilidade. Se cinco ou mais pendem pra Laravel, instala o framework e segue. Em casos no meio termo, a escolha mais segura costuma ser Laravel, porque ele cresce melhor do que PHP puro encolhe. Migrar de Laravel pra PHP puro depois que o projeto cresceu é raro. Migrar de PHP puro pra Laravel quando o escopo aumentou é comum, e custa caro.
Vale ainda uma observação. Existe um meio termo formal nesse debate: frameworks pequenos como o Slim Framework ou o Lumen. Eles entregam só o roteamento e uma estrutura mínima, sem o peso completo do Laravel. Funcionam bem, mas na prática ocupam um espaço estreito. Quando você precisa de pouco, PHP puro com Composer já resolve. Quando você precisa de bastante, Laravel completo entrega mais com menos atrito. O meio termo costuma ser um intermediário que raramente é a escolha ótima.
Com a parte arquitetural resolvida, a próxima metade do artigo desce pro código. Daqui em diante, é PHP sem framework acontecendo de verdade, com quatro cenários reais e a seção que cobre tudo que o framework cuidava por você em silêncio.
5. Setup mínimo de um projeto PHP em 2026
Antes de qualquer cenário, vale alinhar o que conta como "PHP sem framework bem feito" hoje. A resposta não é "abre o Bloco de Notas e começa a escrever index.php". A resposta é: Composer, autoload, estrutura mínima, variáveis de ambiente em arquivo separado, e um único ponto de entrada por aplicação web. Sem isso, qualquer um dos cenários abaixo vira gambiarra.
O que é o Composer e por que ele não é opcional
O Composer é o gerenciador de pacotes do PHP. Se você veio do JavaScript, é o equivalente ao npm. Se veio do Python, é o equivalente ao pip. Ele resolve duas coisas que historicamente eram dolorosas em PHP:
Dependências. Em vez de baixar bibliotecas manualmente e colocar em pastas, você declara o que precisa em um arquivo
composer.jsone rodacomposer install. Tudo entra na pastavendor/, com as versões certas.Autoload. Em vez de espalhar
requireeincludeem todo arquivo, o Composer carrega classes automaticamente conforme você as usa, seguindo um padrão chamado PSR-4 (que basicamente diz "o nome da classe segue o caminho da pasta").
Instalar Composer hoje é tranquilo. Na sua máquina, siga as instruções em getcomposer.org. Em ambientes Linux ou WSL, costuma resolver com algumas linhas no terminal. No Windows, existe instalador gráfico.
Estrutura de pastas que cabe em qualquer projeto
Não precisa ser elaborado. A estrutura abaixo cobre todos os cenários do artigo:
projeto/
├── composer.json
├── composer.lock
├── .env
├── .env.example
├── .gitignore
├── public/
│ ├── index.php
│ ├── api.php
│ ├── webhook.php
│ ├── contato.php
│ └── contato-enviar.php
├── bin/
│ └── importar-produtos.php
├── src/
│ └── (suas classes aqui)
└── vendor/
└── (gerado pelo composer install)Três pastas, três tipos de coisa:
public/é a única pasta que o servidor web tem permissão pra acessar. Tudo que está aqui pode virar uma URL. Mantenha o mínimo possível aqui: idealmente, só o ponto de entrada (index.php) e talvez arquivos estáticos.src/é onde fica sua lógica de negócio. Classes, helpers, módulos. Esse código nunca é acessado diretamente pelo navegador.bin/é onde ficam scripts de linha de comando. Também nunca acessíveis via web.
A regra "só public/ é acessível" é fundamental por segurança. Se um arquivo .env com suas senhas estiver dentro de public/, alguém pode acessar com o navegador. Se estiver fora, não.
O composer.json
Roda composer init e responde as perguntas, ou cria manualmente. Esse é um exemplo realista do que a gente vai usar:
{
"name": "exemplo/php-puro",
"type": "project",
"require": {
"php": "^8.3",
"vlucas/phpdotenv": "^5.6",
"phpmailer/phpmailer": "^6.9"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"config": {
"sort-packages": true
}
}Três coisas pra notar:
requirelista o que o projeto precisa. Aqui pedimos PHP 8.3 ou mais novo, mais dois pacotes que vamos usar adiante (vlucas/phpdotenvpra ler variáveis de ambiente ephpmailer/phpmailerpra enviar e-mail).autoload.psr-4diz "tudo dentro desrc/está no namespaceApp\". Isso significa que uma classe emsrc/Database.phpcomnamespace App;viraApp\Database, e o Composer encontra ela sozinho.config.sort-packagessó mantém a lista de dependências em ordem alfabética. Detalhe estético, mas evita conflito quando duas pessoas mexem no arquivo.
Depois disso, roda composer install e o Composer baixa tudo pra dentro de vendor/.
Variáveis de ambiente com .env
Senhas, tokens, host de banco, dados de SMTP. Nada disso deve ficar dentro do código-fonte, porque o código vai pro Git e alguém vai acabar vendo. O padrão é colocar tudo em um arquivo .env que fica fora do Git (adicionado no .gitignore), e versionar só um .env.example com a estrutura.
Exemplo de .env.example:
APP_ENV=local
APP_DEBUG=true
DB_HOST=localhost
DB_PORT=5432
DB_NAME=exemplo
DB_USER=postgres
DB_PASS=senha_local
MP_ACCESS_TOKEN=seu_token_do_mercado_pago_aqui
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=usuario
SMTP_PASS=senha
SMTP_FROM=site@example.com
CONTATO_DESTINO=voce@example.comO .env de verdade fica do lado, com os valores reais, e nunca vai pro Git. O pacote vlucas/phpdotenv lê esse arquivo e coloca tudo em $_ENV, pra você usar no código.
O ponto de entrada: public/index.php
Em PHP sem framework, é tentador criar um arquivo .php por página. cadastro.php, login.php, painel.php. Funciona em projetos minúsculos, mas vira bagunça rápido. O caminho mais saudável, mesmo sem framework, é ter um único ponto de entrada que decide o que fazer com base na URL. Esse é o front controller.
<?php
// public/index.php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
// Carrega as variáveis de ambiente do arquivo .env
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/..');
$dotenv->load();
// Em desenvolvimento, mostra erros. Em produção, nunca.
if (($_ENV['APP_DEBUG'] ?? 'false') === 'true') {
error_reporting(E_ALL);
ini_set('display_errors', '1');
} else {
ini_set('display_errors', '0');
error_reporting(0);
}
// Descobre o caminho que o navegador pediu
$metodo = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$caminho = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
$caminho = '/' . trim((string) $caminho, '/');
// Roteamento simples
switch ($caminho) {
case '/':
echo 'Página inicial';
break;
case '/contato':
require __DIR__ . '/contato.php';
break;
default:
http_response_code(404);
echo 'Página não encontrada';
}
Algumas coisas importantes nesse arquivo:
declare(strict_types=1);deve estar na primeira linha de todo arquivo PHP novo que você escrever. Ele força que tipos declarados em funções sejam respeitados de verdade. Sem ele, PHP aceita conversões malucas (passar string onde se pediu int, por exemplo) e isso é fonte de bug.require_once __DIR__ . '/../vendor/autoload.php';ativa o autoload do Composer. Sem essa linha, suas classes dosrc/não vão ser encontradas automaticamente.O switch de roteamento é o coração do front controller. Em projetos maiores, dá pra trocar por uma classe de Router, ou por um pacote como o
nikic/fast-route. Pra projetos pequenos, switch resolve.
Pra Apache, ainda falta um .htaccess dentro de public/ redirecionando tudo pra index.php:
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [L]Em Nginx, a configuração equivalente vai no bloco do site, com try_files $uri $uri/ /index.php?$query_string;.
Com esse setup pronto, dá pra implementar qualquer um dos quatro cenários abaixo. Cada um deles vai usar essa mesma base: vendor/autoload.php, variáveis em $_ENV, classes em src/ via PSR-4.
6. Cenário 1: webhook do Mercado Pago
Esse é o cenário mais clássico onde PHP sem framework brilha. O Mercado Pago, igual a praticamente todo gateway de pagamento, manda uma notificação HTTP toda vez que o status de um pagamento muda. Seu endpoint recebe essa notificação, atualiza o pedido no seu banco e responde. Você precisa de uma rota, de acesso ao banco, de uma chamada HTTP saindo, e nada mais. Subir Laravel pra isso é exatamente o caso que a Parte 1 descrevia como exagero.
O fluxo, em palavras
O cliente paga via Mercado Pago.
O Mercado Pago dispara uma requisição POST pro seu endpoint, com um corpo JSON pequeno informando o tipo do evento (
payment,plan, etc.) e o ID do recurso.Seu endpoint precisa responder com status
200rapidamente (idealmente em menos de cinco segundos). Se você demora demais ou responde com erro, o Mercado Pago vai tentar de novo, e você acaba processando o mesmo pagamento múltiplas vezes.Dentro do endpoint, você pega o ID que veio na notificação e consulta a API do Mercado Pago pra obter os detalhes completos do pagamento.
Com os detalhes em mãos, atualiza o pedido associado no seu banco.
Tem dois pontos que separam um webhook amador de um webhook que aguenta produção: idempotência e resposta rápida. Idempotência significa que, se o mesmo evento chegar duas vezes (e vai chegar), você não processa duas vezes. Resposta rápida significa que você não fica esperando operações lentas antes de devolver o 200.
Tabelas no PostgreSQL
A gente vai precisar de duas tabelas. Uma pra registrar os eventos que já chegaram (pra garantir idempotência) e uma pra os pedidos.
CREATE TABLE pedidos (
id SERIAL PRIMARY KEY,
pagamento_id VARCHAR(50) UNIQUE,
status_pagamento VARCHAR(30),
valor NUMERIC(10, 2),
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE eventos_webhook (
id SERIAL PRIMARY KEY,
id_evento VARCHAR(100) NOT NULL UNIQUE,
tipo VARCHAR(50),
payload JSONB,
recebido_em TIMESTAMPTZ NOT NULL DEFAULT NOW()
);O detalhe importante é o UNIQUE em id_evento. Ele é o que vai impedir que o mesmo evento seja processado duas vezes: se você tentar inserir um evento com ID que já existe, o banco rejeita e você sabe que pode ignorar.
O endpoint completo
Coloca isso em public/webhook.php:
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/..');
$dotenv->load();
// 1. Aceita só POST. Qualquer outro método é erro.
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit;
}
// 2. Lê o corpo cru da requisição (não dá pra usar $_POST aqui,
// porque o Mercado Pago manda JSON, não form-urlencoded)
$corpo = file_get_contents('php://input');
$payload = json_decode($corpo, true);
if (!is_array($payload)) {
http_response_code(400);
exit;
}
// 3. Conecta no PostgreSQL
$pdo = new PDO(
sprintf(
'pgsql:host=%s;port=%s;dbname=%s',
$_ENV['DB_HOST'],
$_ENV['DB_PORT'],
$_ENV['DB_NAME']
),
$_ENV['DB_USER'],
$_ENV['DB_PASS'],
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
// 4. Tenta registrar o evento. Se já existe (UNIQUE constraint violada),
// significa que é um evento repetido. Responde 200 e encerra.
$idEvento = (string) ($payload['id'] ?? '');
$tipo = (string) ($payload['type'] ?? '');
if ($idEvento === '') {
http_response_code(400);
exit;
}
try {
$stmt = $pdo->prepare(
'INSERT INTO eventos_webhook (id_evento, tipo, payload) VALUES (?, ?, ?)'
);
$stmt->execute([$idEvento, $tipo, $corpo]);
} catch (PDOException $e) {
// SQLSTATE 23505 = violação de UNIQUE no PostgreSQL
if ($e->errorInfo[0] === '23505') {
http_response_code(200);
exit;
}
error_log('Webhook MP, erro ao gravar evento: ' . $e->getMessage());
http_response_code(500);
exit;
}
// 5. Por enquanto, só processa tipo "payment". Outros tipos só ficam
// registrados em eventos_webhook, sem ação adicional.
if ($tipo !== 'payment') {
http_response_code(200);
exit;
}
$pagamentoId = (string) ($payload['data']['id'] ?? '');
if ($pagamentoId === '') {
http_response_code(400);
exit;
}
// 6. Consulta o pagamento na API do Mercado Pago pra obter os detalhes
$ch = curl_init('https://api.mercadopago.com/v1/payments/' . $pagamentoId);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $_ENV['MP_ACCESS_TOKEN'],
'Content-Type: application/json',
],
CURLOPT_TIMEOUT => 5,
]);
$resposta = curl_exec($ch);
$codigoHttp = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($codigoHttp !== 200 || $resposta === false) {
error_log("Webhook MP, falha ao consultar pagamento {$pagamentoId}, http {$codigoHttp}");
// Responde 500 pra que o Mercado Pago tente entregar de novo depois.
// Como o evento já foi gravado em eventos_webhook, a próxima entrega
// vai falhar na inserção (23505) e a gente pode reprocessar no nosso ritmo.
http_response_code(500);
exit;
}
$pagamento = json_decode((string) $resposta, true);
if (!is_array($pagamento)) {
http_response_code(500);
exit;
}
// 7. Atualiza o pedido. Usa UPSERT (INSERT ... ON CONFLICT) pra cobrir
// o caso de o pedido ainda não existir no banco.
$stmt = $pdo->prepare(
'INSERT INTO pedidos (pagamento_id, status_pagamento, valor)
VALUES (?, ?, ?)
ON CONFLICT (pagamento_id)
DO UPDATE SET
status_pagamento = EXCLUDED.status_pagamento,
valor = EXCLUDED.valor,
atualizado_em = NOW()'
);
$stmt->execute([
(string) $pagamento['id'],
(string) $pagamento['status'],
(float) $pagamento['transaction_amount'],
]);
http_response_code(200);
O que esse código entrega
Idempotência via banco. Se o mesmo evento chegar duas vezes, a segunda tentativa de inserir em
eventos_webhookvai falhar noUNIQUEe a gente responde200sem repetir o trabalho.Auditoria. Toda notificação recebida fica registrada em
eventos_webhook, inclusive o payload original. Se algo der errado, você consegue investigar depois.Timeout curto no curl. Cinco segundos pra responder à API do Mercado Pago. Se demorar mais, o curl aborta e o endpoint devolve
500. Isso protege contra cenários onde a API do gateway está lenta e seu endpoint ficaria travado.Logs em
error_log. Erros importantes vão pro log de erros do PHP, que em servidores normais já está rotacionado e centralizado.
Esse arquivo tem cerca de 90 linhas. Em Laravel, o equivalente seria similar em volume, mas com uma estrutura de controller, request validado, service container e mais alguns megabytes carregados em memória. Pra um endpoint isolado, a versão sem framework é honestamente mais simples.
Um passo a mais pra produção: validar a assinatura
O Mercado Pago, assim como outros gateways, oferece uma forma de validar que a notificação veio mesmo do servidor deles e não de alguém tentando enganar seu sistema. Isso é feito com um cabeçalho HTTP chamado x-signature, que carrega um hash baseado em uma chave secreta que só você e o Mercado Pago conhecem. Em produção real, é recomendado validar essa assinatura antes de processar qualquer coisa.
Pra manter o foco do artigo, a gente não vai implementar a validação aqui. Mas fica o aviso: antes de subir esse endpoint pra um ambiente que recebe pagamentos de verdade, leia a documentação atual do Mercado Pago sobre validação de notificações e adicione esse passo no começo do arquivo.
7. Cenário 2: API REST mínima com PostgreSQL
Segundo cenário: uma API REST simples pra gerenciar produtos. Você tem uma tabela, quer poder listar, buscar um, criar, atualizar e remover via HTTP, com JSON entrando e JSON saindo. É o tipo de coisa que aparece em integrações pontuais, dashboards internos, ou aplicações pequenas onde o front é separado do back.
A tabela
CREATE TABLE produtos (
id SERIAL PRIMARY KEY,
sku VARCHAR(50) NOT NULL UNIQUE,
nome VARCHAR(200) NOT NULL,
preco NUMERIC(10, 2) NOT NULL CHECK (preco >= 0),
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW()
);O endpoint
Aqui a gente vai fazer tudo em um único arquivo public/api.php, pra deixar claro o fluxo. Em um projeto maior, dividir em controllers e services dentro de src/ faz sentido. Pra cinco rotas, manter junto é mais fácil de ler.
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/..');
$dotenv->load();
header('Content-Type: application/json; charset=utf-8');
// 1. Descobre o método HTTP e o caminho
$metodo = $_SERVER['REQUEST_METHOD'];
$caminho = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
$partes = explode('/', trim((string) $caminho, '/'));
// Espera URLs no formato /produtos ou /produtos/{id}
if (($partes[0] ?? '') !== 'produtos') {
http_response_code(404);
echo json_encode(['erro' => 'Rota não encontrada']);
exit;
}
$id = isset($partes[1]) ? filter_var($partes[1], FILTER_VALIDATE_INT) : null;
if (isset($partes[1]) && $id === false) {
http_response_code(400);
echo json_encode(['erro' => 'ID inválido']);
exit;
}
// 2. Conecta no PostgreSQL
try {
$pdo = conectar();
} catch (PDOException $e) {
error_log('API, falha na conexão: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['erro' => 'Erro interno']);
exit;
}
// 3. Roteia para a função certa baseada em método + presença de ID
try {
if ($metodo === 'GET' && $id === null) {
listar($pdo);
} elseif ($metodo === 'GET' && $id !== null) {
buscar($pdo, $id);
} elseif ($metodo === 'POST' && $id === null) {
criar($pdo);
} elseif ($metodo === 'PUT' && $id !== null) {
atualizar($pdo, $id);
} elseif ($metodo === 'DELETE' && $id !== null) {
remover($pdo, $id);
} else {
http_response_code(405);
echo json_encode(['erro' => 'Método não permitido para essa rota']);
}
} catch (PDOException $e) {
error_log('API, erro de banco: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['erro' => 'Erro interno']);
}
function conectar(): PDO {
return new PDO(
sprintf(
'pgsql:host=%s;port=%s;dbname=%s',
$_ENV['DB_HOST'],
$_ENV['DB_PORT'],
$_ENV['DB_NAME']
),
$_ENV['DB_USER'],
$_ENV['DB_PASS'],
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
}
function listar(PDO $pdo): void {
$stmt = $pdo->query(
'SELECT id, sku, nome, preco FROM produtos ORDER BY id DESC LIMIT 100'
);
echo json_encode($stmt->fetchAll());
}
function buscar(PDO $pdo, int $id): void {
$stmt = $pdo->prepare('SELECT id, sku, nome, preco FROM produtos WHERE id = ?');
$stmt->execute([$id]);
$produto = $stmt->fetch();
if ($produto === false) {
http_response_code(404);
echo json_encode(['erro' => 'Produto não encontrado']);
return;
}
echo json_encode($produto);
}
function criar(PDO $pdo): void {
$dados = lerCorpoJson();
$erros = validar($dados);
if (!empty($erros)) {
http_response_code(422);
echo json_encode(['erros' => $erros]);
return;
}
$stmt = $pdo->prepare(
'INSERT INTO produtos (sku, nome, preco)
VALUES (?, ?, ?)
RETURNING id, sku, nome, preco'
);
$stmt->execute([$dados['sku'], $dados['nome'], $dados['preco']]);
$produto = $stmt->fetch();
http_response_code(201);
echo json_encode($produto);
}
function atualizar(PDO $pdo, int $id): void {
$dados = lerCorpoJson();
$erros = validar($dados);
if (!empty($erros)) {
http_response_code(422);
echo json_encode(['erros' => $erros]);
return;
}
$stmt = $pdo->prepare(
'UPDATE produtos SET sku = ?, nome = ?, preco = ?
WHERE id = ?
RETURNING id, sku, nome, preco'
);
$stmt->execute([$dados['sku'], $dados['nome'], $dados['preco'], $id]);
$produto = $stmt->fetch();
if ($produto === false) {
http_response_code(404);
echo json_encode(['erro' => 'Produto não encontrado']);
return;
}
echo json_encode($produto);
}
function remover(PDO $pdo, int $id): void {
$stmt = $pdo->prepare('DELETE FROM produtos WHERE id = ?');
$stmt->execute([$id]);
if ($stmt->rowCount() === 0) {
http_response_code(404);
echo json_encode(['erro' => 'Produto não encontrado']);
return;
}
http_response_code(204);
}
function lerCorpoJson(): ?array {
$corpo = file_get_contents('php://input');
$dados = json_decode((string) $corpo, true);
return is_array($dados) ? $dados : null;
}
function validar(?array $dados): array {
$erros = [];
if ($dados === null) {
return ['Corpo inválido. Esperado JSON.'];
}
if (empty($dados['sku']) || !is_string($dados['sku']) || strlen($dados['sku']) > 50) {
$erros[] = 'O campo sku é obrigatório, string, e deve ter no máximo 50 caracteres.';
}
if (empty($dados['nome']) || !is_string($dados['nome']) || strlen($dados['nome']) > 200) {
$erros[] = 'O campo nome é obrigatório, string, e deve ter no máximo 200 caracteres.';
}
if (!isset($dados['preco']) || !is_numeric($dados['preco']) || $dados['preco'] < 0) {
$erros[] = 'O campo preco é obrigatório, numérico, e deve ser maior ou igual a zero.';
}
return $erros;
}
Detalhes que separam essa API de uma API ruim
Todo SELECT, INSERT, UPDATE e DELETE usa
preparecom placeholders. Nenhuma string é concatenada na query. Isso protege contra SQL injection (vamos voltar nesse tema na seção 10).PDO::ATTR_EMULATE_PREPARES => falsediz pro PHP usar prepared statements de verdade no servidor de banco, em vez da emulação que o PDO faz por padrão. A diferença é técnica, mas vale: prepared statements reais protegem melhor.Status HTTP corretos.
200pra OK,201pra criado,204pra removido com sucesso,400pra requisição malformada,404pra não encontrado,405pra método errado,422pra erro de validação,500pra erro interno. Cliente da API consegue diferenciar problemas.Validação manual mas honesta. Sem framework, validação vira função sua. Não é o mais elegante, mas funciona, e o leitor consegue ver tudo que está acontecendo.
Sem dado sensível no log de erro. O
error_logrecebe a mensagem da exceção, mas a resposta pro cliente é apenas"Erro interno". Você nunca quer vazar detalhes do banco numa resposta HTTP.
Como evolui daqui
Em um projeto que cresce, esse arquivo único naturalmente vai virar várias classes. Algo como:
src/Database.phpcom a função de conectar.src/Produtos/Repositorio.phpcom os métodos de banco.src/Produtos/Validador.phpcom as regras.src/Produtos/Controlador.phpcoordenando tudo.public/api.phpsó roteando.
O ponto é: você pode começar simples e migrar pra estrutura à medida que a complexidade exige. Sem framework não significa sem estrutura. Significa que a estrutura cresce no ritmo que o projeto precisa, não antes.
8. Cenário 3: importação de CSV via linha de comando
Terceiro cenário: um script que roda fora do navegador, agendado por cron, pra importar uma planilha. Caso típico: o fornecedor manda uma lista de produtos atualizada por e-mail toda semana, ou um sistema legado exporta dados em CSV todo dia. Esses scripts são onde Laravel é mais peso morto, porque você está rodando uma vez e saindo, sem servir requisição nenhuma.
O CSV de entrada
Vamos supor um arquivo com três colunas: sku, nome, preco. Primeira linha é cabeçalho. Algo assim:
sku,nome,preco
SKU001,Camiseta básica branca,49.90
SKU002,Camiseta básica preta,49.90
SKU003,Caneca personalizada,29.90O script
Coloca em bin/importar-produtos.php:
#!/usr/bin/env php
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/..');
$dotenv->load();
// 1. Lê opções da linha de comando
// --arquivo=caminho/para/arquivo.csv (obrigatório)
// --dry-run (opcional, simula sem gravar)
$opcoes = getopt('', ['arquivo:', 'dry-run']);
if (!isset($opcoes['arquivo'])) {
fwrite(STDERR, "Uso: php bin/importar-produtos.php --arquivo=caminho.csv [--dry-run]\n");
exit(1);
}
$arquivo = (string) $opcoes['arquivo'];
$dryRun = isset($opcoes['dry-run']);
if (!is_readable($arquivo)) {
fwrite(STDERR, "Arquivo não encontrado ou sem permissão de leitura: {$arquivo}\n");
exit(1);
}
// 2. Abre o arquivo e lê o cabeçalho
$handle = fopen($arquivo, 'r');
if ($handle === false) {
fwrite(STDERR, "Falha ao abrir o arquivo\n");
exit(1);
}
$cabecalho = fgetcsv($handle);
if ($cabecalho === false) {
fwrite(STDERR, "Arquivo vazio\n");
exit(1);
}
// 3. Conecta no banco
$pdo = new PDO(
sprintf(
'pgsql:host=%s;port=%s;dbname=%s',
$_ENV['DB_HOST'],
$_ENV['DB_PORT'],
$_ENV['DB_NAME']
),
$_ENV['DB_USER'],
$_ENV['DB_PASS'],
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
// 4. Prepara o statement uma única vez. Vai ser reutilizado pra cada linha.
// Usa UPSERT: se o sku já existe, atualiza. Se não, insere.
$stmt = $pdo->prepare(
'INSERT INTO produtos (sku, nome, preco) VALUES (?, ?, ?)
ON CONFLICT (sku) DO UPDATE SET
nome = EXCLUDED.nome,
preco = EXCLUDED.preco'
);
// 5. Inicia transação (só fora de dry-run). Importar mil linhas em uma
// transação é muito mais rápido que mil transações separadas.
if (!$dryRun) {
$pdo->beginTransaction();
}
$processados = 0;
$erros = 0;
$linha = 1;
while (($dados = fgetcsv($handle)) !== false) {
$linha++;
if (count($dados) < 3) {
fwrite(STDERR, "Linha {$linha}: colunas insuficientes, pulando\n");
$erros++;
continue;
}
[$sku, $nome, $preco] = $dados;
// Validação básica
if ($sku === '' || $nome === '') {
fwrite(STDERR, "Linha {$linha}: sku ou nome vazio, pulando\n");
$erros++;
continue;
}
if (!is_numeric($preco) || (float) $preco < 0) {
fwrite(STDERR, "Linha {$linha}: preço inválido ({$preco}), pulando\n");
$erros++;
continue;
}
if ($dryRun) {
echo "[dry-run] importaria: {$sku} | {$nome} | {$preco}\n";
$processados++;
continue;
}
try {
$stmt->execute([$sku, $nome, (float) $preco]);
$processados++;
} catch (PDOException $e) {
fwrite(STDERR, "Linha {$linha}: erro no banco, {$e->getMessage()}\n");
$erros++;
}
}
fclose($handle);
// 6. Confirma a transação (commit), ou cancela tudo (rollback)
// se quiser ser mais rigoroso com erros.
if (!$dryRun) {
$pdo->commit();
}
// 7. Resumo final e exit code apropriado
$modo = $dryRun ? 'simulação' : 'real';
echo "Concluído ({$modo}). Processadas: {$processados}. Erros: {$erros}.\n";
exit($erros > 0 ? 2 : 0);
O que esse script entrega de produção-friendly
Flag
--dry-run. Antes de importar mil linhas no banco real, dá pra rodar com--dry-rune ver no terminal o que ia acontecer. Esse padrão deveria ser obrigatório em qualquer script que mexe em banco.Idempotência por
sku. OON CONFLICT (sku) DO UPDATEfaz com que rodar o script duas vezes seguidas com o mesmo arquivo não cause duplicação. Se o produto já existe pelo sku, ele atualiza em vez de inserir.Transação única. Todas as inserções acontecem dentro de uma transação. Isso é muito mais rápido em volumes grandes e dá atomicidade: ou tudo entra, ou nada entra (basta trocar
commit()porrollBack()em caso de erro grave).Mensagens de erro pro stderr, não pro stdout. O
fwrite(STDERR, ...)separa erros da saída normal. Quem está orquestrando o script (cron, CI, supervisor) consegue diferenciar.Exit codes diferentes.
0pra sucesso total,2pra "rodou mas teve erros". Quem chama o script pode reagir baseado no código de saída.
Rodando no terminal:
php bin/importar-produtos.php --arquivo=/caminho/produtos.csv --dry-run
php bin/importar-produtos.php --arquivo=/caminho/produtos.csvPra agendar via cron, é uma linha:
0 3 * * * php /var/www/projeto/bin/importar-produtos.php --arquivo=/var/imports/produtos.csvIsso roda todo dia às 3h da manhã. Em Laravel, o equivalente envolveria criar uma Artisan Command, registrar o agendamento, e ter o projeto inteiro carregado em memória pra rodar 120 linhas de código. Sem framework, é um arquivo que faz exatamente o que precisa fazer.
9. Cenário 4: formulário de contato com CSRF e envio por SMTP
Último cenário e provavelmente o mais comum em qualquer site pequeno: um formulário de contato que recebe nome, e-mail, mensagem, e manda tudo pro seu e-mail. Parece simples, mas é onde site pequeno mais vaza dado, recebe spam e leva ataque. Vamos fazer direito desde o começo.
A página do formulário
Em public/contato.php:
<?php
declare(strict_types=1);
session_start();
// Gera token CSRF se ainda não existe na sessão
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$csrf = $_SESSION['csrf_token'];
?>
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<title>Contato</title>
</head>
<body>
<form action="/contato-enviar.php" method="POST">
<input type="hidden" name="csrf_token"
value="<?= htmlspecialchars($csrf, ENT_QUOTES | ENT_HTML5) ?>">
<!-- Honeypot: campo invisível pra humano, mas bot preenche -->
<input type="text" name="empresa" style="display:none"
tabindex="-1" autocomplete="off">
<p>
<label>Nome
<input type="text" name="nome" required maxlength="100">
</label>
</p>
<p>
<label>E-mail
<input type="email" name="email" required maxlength="200">
</label>
</p>
<p>
<label>Mensagem
<textarea name="mensagem" required maxlength="2000"></textarea>
</label>
</p>
<button type="submit">Enviar</button>
</form>
</body>
</html>Três detalhes que importam aqui:
Token CSRF no
hidden. Esse token é único pra sessão do usuário, e vai ser verificado no envio. Se alguém tentar enviar o formulário de outro site (ataque CSRF), não vai conseguir adivinhar esse token.Honeypot. O campo
empresaestá escondido visualmente, e marcado pra não receber foco com Tab. Usuário humano não vê e não preenche. Bot que faz scraping de campos do formulário enche tudo, incluindo esse. No backend a gente descarta qualquer envio com esse campo preenchido.htmlspecialcharscom flags certas. O token está sendo impresso dentro de um atributo HTML. Sem escape, alguém poderia injetar HTML por aí (vamos voltar nesse tema na seção 10). As flagsENT_QUOTES | ENT_HTML5garantem escape correto.
O processador
Em public/contato-enviar.php:
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception as MailException;
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/..');
$dotenv->load();
session_start();
// 1. Só aceita POST
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit('Método não permitido');
}
// 2. Honeypot. Se o campo "empresa" foi preenchido, é bot.
// Responde 204 (sucesso sem conteúdo) pra não dar feedback ao bot
// sobre o motivo da rejeição.
if (!empty($_POST['empresa'])) {
http_response_code(204);
exit;
}
// 3. Verifica o token CSRF
$tokenEnviado = (string) ($_POST['csrf_token'] ?? '');
$tokenSessao = (string) ($_SESSION['csrf_token'] ?? '');
if ($tokenSessao === '' || !hash_equals($tokenSessao, $tokenEnviado)) {
http_response_code(403);
exit('Token de proteção inválido. Recarregue a página e tente novamente.');
}
// 4. Validação dos campos
$nome = trim((string) ($_POST['nome'] ?? ''));
$email = trim((string) ($_POST['email'] ?? ''));
$mensagem = trim((string) ($_POST['mensagem'] ?? ''));
$erros = [];
if ($nome === '' || strlen($nome) > 100) {
$erros[] = 'Nome inválido. Informe um nome de até 100 caracteres.';
}
if ($email === '' || filter_var($email, FILTER_VALIDATE_EMAIL) === false || strlen($email) > 200) {
$erros[] = 'E-mail inválido.';
}
if ($mensagem === '' || strlen($mensagem) > 2000) {
$erros[] = 'Mensagem inválida. Use até 2000 caracteres.';
}
if (!empty($erros)) {
http_response_code(422);
echo 'Não foi possível enviar:' . PHP_EOL;
foreach ($erros as $erro) {
echo '- ' . htmlspecialchars($erro, ENT_QUOTES | ENT_HTML5) . PHP_EOL;
}
exit;
}
// 5. Envia o e-mail via PHPMailer com SMTP
$mail = new PHPMailer(true);
try {
$mail->isSMTP();
$mail->Host = (string) $_ENV['SMTP_HOST'];
$mail->Port = (int) $_ENV['SMTP_PORT'];
$mail->SMTPAuth = true;
$mail->Username = (string) $_ENV['SMTP_USER'];
$mail->Password = (string) $_ENV['SMTP_PASS'];
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->CharSet = 'UTF-8';
$mail->setFrom((string) $_ENV['SMTP_FROM'], 'Formulário do site');
$mail->addAddress((string) $_ENV['CONTATO_DESTINO']);
$mail->addReplyTo($email, $nome);
$mail->Subject = 'Novo contato pelo site';
$mail->Body = sprintf(
"Nome: %s\nE-mail: %s\n\nMensagem:\n%s",
$nome,
$email,
$mensagem
);
$mail->send();
} catch (MailException $e) {
error_log('Falha ao enviar contato, ' . $mail->ErrorInfo);
http_response_code(500);
exit('Não foi possível enviar agora. Tente novamente em alguns minutos.');
}
// 6. Regenera o token CSRF pra próxima requisição e redireciona
unset($_SESSION['csrf_token']);
session_regenerate_id(true);
header('Location: /contato-enviado.html');
exit;
Por que esse processador faz o que faz
Honeypot antes do CSRF. Se o request veio de bot, descarta cedo, sem nem verificar token. Reduz carga no servidor.
hash_equalsem vez de===. Ao comparar dois tokens, usar===abre brecha pra um ataque chamado timing attack. Ohash_equalsfaz a comparação em tempo constante, fechando essa brecha. Detalhe pequeno, importante.filter_var($email, FILTER_VALIDATE_EMAIL). É o jeito padrão de validar e-mail em PHP. Não pega 100% dos casos exóticos, mas evita os erros mais comuns.PHPMailer com SMTP. Em vez de usar a função
mail()nativa do PHP, que em produção é tiro no pé (cai em spam, sem retry, sem autenticação), a gente conecta em um servidor SMTP real. Pode ser o do Gmail, da sua hospedagem, de um serviço transacional como Mailgun, SendGrid, Resend.addReplyTocom o e-mail do remetente. OFromprecisa ser um endereço autorizado no SMTP que está enviando (senão o servidor bloqueia). OReply-Tocom o e-mail real do usuário faz com que, quando você clicar em "Responder" no seu cliente de e-mail, vá pro contato e não pro próprio site.session_regenerate_id(true). Depois de uma ação importante, regenerar o ID da sessão é uma defesa adicional contra ataques de fixação de sessão.
Esse formulário é vinte vezes mais robusto que o "formulário de contato" típico que se encontra em sites de pequenos clientes Brasil afora. E continua sendo PHP sem framework.
10. O que o framework fazia por você (e como fazer direito sozinho)
Essa é a seção mais importante do artigo, e a razão de PHP sem framework exigir disciplina. Vários problemas de segurança que o Laravel resolve em silêncio, sem você sequer perceber que existem, voltam pro seu colo. Vamos passar pelos principais com exemplos lado a lado de "errado" e "certo".
SQL injection
O ataque mais antigo e ainda mais comum. O atacante coloca pedaço de SQL dentro de um campo do formulário, e se o seu código concatena esse valor direto na query, o ataque executa.
Errado, concatenando direto:
$email = $_POST['email'];
$resultado = $pdo->query("SELECT * FROM usuarios WHERE email = '{$email}'");Se alguém manda email=' OR '1'='1, a query final fica SELECT * FROM usuarios WHERE email = '' OR '1'='1', e retorna todo mundo.
Certo, usando prepared statements:
$stmt = $pdo->prepare('SELECT * FROM usuarios WHERE email = ?');
$stmt->execute([$_POST['email']]);
$usuario = $stmt->fetch();Com prepare e execute, o PostgreSQL trata o valor como dado, nunca como código SQL. Não tem como o atacante escapar dessa proteção. Regra: nenhum dado vindo do usuário entra na query por concatenação. Se você ver uma string com . ou interpolação de variável dentro de $pdo->query(), está errado.
XSS (Cross-Site Scripting)
O atacante coloca HTML ou JavaScript dentro de um campo. Se o seu código mostra esse valor na página sem escape, o script roda no navegador de quem ver a página.
Errado, ecoando direto:
echo '<p>Olá, ' . $_GET['nome'] . '</p>';Se alguém acessa ?nome=<script>roubarCookie()</script>, o script executa.
Certo, sempre com htmlspecialchars:
echo '<p>Olá, ' . htmlspecialchars($_GET['nome'], ENT_QUOTES | ENT_HTML5, 'UTF-8') . '</p>';As flags ENT_QUOTES | ENT_HTML5 garantem que aspas simples e duplas também sejam escapadas, e que o tratamento siga regras de HTML5. O UTF-8 explícito evita ambiguidade de codificação. Regra: nenhum dado vindo do usuário sai pro HTML sem passar por htmlspecialchars. Se você for usar muito, vale criar uma função e($s) de uma linha pra economizar digitação.
CSRF
Já vimos no formulário de contato. O atacante cria uma página em outro site que faz submit pro seu sistema, aproveitando a sessão logada do usuário. Sem token CSRF, qualquer POST de qualquer origem é aceito.
Errado, sem token:
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// ... processa direto
}Certo, exigindo e validando token:
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$tokenEnviado = (string) ($_POST['csrf_token'] ?? '');
$tokenSessao = (string) ($_SESSION['csrf_token'] ?? '');
if ($tokenSessao === '' || !hash_equals($tokenSessao, $tokenEnviado)) {
http_response_code(403);
exit('Token inválido');
}
// ... agora sim, processa
}Regra: todo POST que altera estado precisa ter token CSRF gerado na exibição do formulário e validado no envio.
Sessões seguras
Por padrão, o PHP cria cookies de sessão razoáveis, mas não ideais. Vale configurar antes de chamar session_start().
Errado, defaults:
session_start();Certo, configuração explícita:
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => true, // só envia o cookie via HTTPS
'httponly' => true, // JavaScript não consegue ler o cookie
'samesite' => 'Lax', // bloqueia envio em requisições cross-site
]);
session_start();
// Em pontos sensíveis (login, mudança de senha), regenera o ID
session_regenerate_id(true);O httponly em especial bloqueia uma classe inteira de ataques: se um XSS escapar pela rede, o atacante ainda não consegue ler o cookie de sessão via JavaScript. Regra: defina os parâmetros de cookie antes de session_start(), e regenere o ID depois de login.
Uploads de arquivo
O lugar onde mais gente leva ataque sem perceber. Validar extensão não é suficiente, qualquer um pode renomear um .php pra .jpg.
Errado, confiando na extensão:
$nome = $_FILES['foto']['name'];
move_uploaded_file($_FILES['foto']['tmp_name'], __DIR__ . '/uploads/' . $nome);Isso permite que alguém suba shell.php renomeado como imagem.jpg, e depois acesse o arquivo na URL pra executar PHP no seu servidor.
Certo, validando MIME real e salvando fora da pasta pública:
if (!isset($_FILES['foto']) || $_FILES['foto']['error'] !== UPLOAD_ERR_OK) {
http_response_code(400);
exit('Falha no upload');
}
// Detecta o tipo real do arquivo (não confia na extensão nem no $_FILES['type'])
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($_FILES['foto']['tmp_name']);
$tiposPermitidos = ['image/jpeg', 'image/png', 'image/webp'];
if (!in_array($mime, $tiposPermitidos, true)) {
http_response_code(422);
exit('Tipo de arquivo não permitido');
}
if ($_FILES['foto']['size'] > 5 * 1024 * 1024) {
http_response_code(422);
exit('Arquivo muito grande, limite 5MB');
}
// Salva com nome aleatório, em pasta FORA do diretório público
$nomeNovo = bin2hex(random_bytes(16)) . '.' . match ($mime) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
};
$destino = __DIR__ . '/../uploads-privados/' . $nomeNovo;
move_uploaded_file($_FILES['foto']['tmp_name'], $destino);Três defesas combinadas: validar o MIME real, gerar nome aleatório (atacante não consegue prever o caminho), e salvar fora de public/ (mesmo que suba um .php, ele não vai ser acessível via URL).
Quando precisar exibir a imagem, você cria uma rota tipo /imagem/{id} que lê o arquivo do disco e devolve com o cabeçalho Content-Type correto.
Headers de segurança
Vários cabeçalhos HTTP modernos protegem contra ataques específicos. O Laravel adiciona alguns por padrão. Sem framework, você adiciona manualmente, idealmente no front controller, antes de qualquer saída.
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: SAMEORIGIN');
header('Referrer-Policy: strict-origin-when-cross-origin');
// CSP é mais complexo, ajuste pra sua aplicação
header(
"Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self'"
);
// Se você só serve HTTPS (e em 2026 não tem mais desculpa)
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');O que cada um faz, em uma linha:
X-Content-Type-Options. Impede que o navegador "adivinhe" o tipo do conteúdo (e abra brechas).
X-Frame-Options. Impede que seu site seja exibido dentro de um
<iframe>de outro domínio (proteção contra clickjacking).Referrer-Policy. Controla o que vai no header
Refererquando o usuário sai do seu site.Content-Security-Policy. Whitelist de fontes confiáveis pra scripts, imagens e estilos. Bloqueia execução de scripts injetados via XSS, é a defesa em profundidade.
Strict-Transport-Security. Força que o navegador só acesse seu site via HTTPS dali pra frente.
11. PHP moderno na medida certa
Pra fechar a parte técnica, vale mostrar que PHP em 2026 é uma linguagem bem diferente do PHP que sobreviveu em fóruns de 2010. Algumas mudanças relativamente recentes fazem o seu código sem framework ficar muito mais seguro e legível. Não precisa decorar todas, mas vale conhecer essas quatro:
declare(strict_types=1)
Já apareceu em todo arquivo do artigo. Coloca essa linha no topo, sempre. O efeito é que tipos declarados em parâmetros e retornos passam a ser respeitados de verdade. Sem isso, PHP faz conversão automática silenciosa (passar "5" onde se pediu int vira 5), o que esconde bugs.
<?php
declare(strict_types=1);
function somar(int $a, int $b): int {
return $a + $b;
}
somar(1, 2); // ok, retorna 3
somar('1', '2'); // TypeError, exatamente o que você querTipos em parâmetros e retornos
Toda função e método deve ter tipo em todo parâmetro e tipo de retorno. Tipo de retorno void pra funções que não retornam nada. ?string pra "string ou null". O ganho em legibilidade e em ferramentas (IDE, analisadores estáticos como o PHPStan) é enorme.
function buscarUsuario(int $id): ?array {
// ... busca no banco
// retorna o array do usuário ou null se não achar
}
function registrarLog(string $mensagem): void {
error_log($mensagem);
}Classes com propriedades tipadas
Em vez do antigo public $nome; sem informação, declare o tipo:
class Produto {
public int $id;
public string $sku;
public string $nome;
public float $preco;
}O PHP valida o tipo na atribuição e dá erro se você tentar guardar algo errado. Em projetos pequenos sem framework, dá pra ir longe usando essas classes simples como objetos de dado, sem precisar de Eloquent nem de container de injeção.
Composer não é frescura, é estrutura
O ponto que vale repetir, porque muito iniciante ainda acha que "PHP puro" significa "sem Composer". Não significa. PHP em 2026 sem Composer é trabalho dobrado, é não conseguir usar bibliotecas modernas, e é abrir mão de autoload por PSR-4. Composer não é o framework. É o equivalente ao npm ou pip: ferramenta básica da linguagem em 2026.
12. Quando partir pro Laravel sem hesitar
Com tudo isso na mesa, a pergunta inversa fica mais clara. Quando o caminho honesto é abandonar PHP sem framework e ir pro Laravel?
Quando você passa de aproximadamente 15 ou 20 rotas, ou prevê que vai passar. O roteamento manual cresce mal a partir desse ponto.
Quando você precisa de autenticação completa. Login, registro, recuperação de senha, lembrar-me, perfis. Reimplementar tudo isso sem framework é trabalho de semanas com risco real de bug de segurança.
Quando o projeto vai ter mais de duas ou três pessoas mexendo nele. A consistência que o framework impõe vira mais valiosa que qualquer ganho de simplicidade.
Quando você precisa de filas, agendamento integrado, e-mail em massa, broadcasting em tempo real. Montar essa infraestrutura sem framework é projeto à parte.
Quando o domínio é complexo. Múltiplos perfis de usuário, regras de negócio com muitas exceções, cálculos que dependem de várias entidades. Framework te dá lugar pra organizar essa complexidade.
Quando o projeto vai durar anos. Convenções compartilhadas envelhecem melhor que código artesanal.
Se três ou quatro desses pontos batem, instala o Laravel e segue tranquilo. Não tem prêmio por escrever tudo na unha quando o framework resolveria em metade do tempo.
13. Onde isso vai rodar
Um ponto que sai do escopo desse artigo mas merece nota: tudo que a gente construiu aqui roda em praticamente qualquer ambiente PHP. Hospedagem compartilhada barata costuma servir, desde que ofereça PHP 8.x e PostgreSQL (ou MySQL, com adaptação no DSN do PDO).
Em hospedagem compartilhada, o ajuste principal é apontar o public_html do painel pra pasta public/ do seu projeto, em vez do diretório raiz. Isso garante que arquivos sensíveis como .env e composer.json nunca fiquem acessíveis pela web.
Em VPS, o setup com Nginx mais PHP-FPM dá mais controle, melhor performance e mais espaço pra crescer. A configuração mínima envolve um bloco server apontando pra public/, um try_files redirecionando tudo pra index.php, e o fastcgi_pass pro socket do PHP-FPM.
Esse tema, com configuração passo a passo, vai virar um post separado. Quando sair, eu volto aqui e linko.
14. Encerramento
PHP sem framework em 2026 não é resistência a Laravel, e não é nostalgia. É uma escolha técnica que cabe bem em uma faixa específica de problemas: endpoints pequenos, scripts de manutenção, integrações pontuais, sites de pouco dinamismo, plugins e extensões, ambientes com restrição de infraestrutura. Nessa faixa, framework cobra um preço que você não precisa pagar.
A diferença entre fazer isso bem e fazer isso mal não está em escolher PHP puro. Está em saber assumir as responsabilidades que vêm junto. Composer e autoload, em vez de require espalhado. Prepared statements em toda query. Escape em toda saída pra HTML. CSRF em todo formulário. Sessões configuradas com cuidado. Uploads validados pelo MIME real, salvos fora do diretório público. Headers de segurança no front controller. Tipos em toda função.
Nada disso é difícil. Mas precisa estar sempre presente. Em Laravel, esses cuidados vêm de graça e em silêncio. Em PHP sem framework, são você que coloca.
Quando essas práticas viram reflexo, PHP sem framework deixa de ser "PHP puro" no sentido pejorativo e passa a ser exatamente o que sempre poderia ter sido: ferramenta enxuta e competente pra problemas que pediam exatamente uma ferramenta enxuta e competente.