Dia 17 — MVC e projeto final: PHP e MySQL
O curso fecha juntando as duas metades: a organização em camadas do dia 15 e os objetos do dia 16 viram a estrutura MVC, com rotas, controller, model e view, e um projeto de cadastro de alunos que usa tudo ao mesmo tempo. Depois deste dia a estrutura está completa; o que sobra é profundidade em cada camada.
Padrão MVC
MVC é o padrão que separa a aplicação em três camadas que não se misturam:
o model guarda o dado e a regra de negócio, a view monta o HTML e o
controller decide qual view mostrar para cada URL. Não é um framework do
PHP, é uma convenção de nomes de arquivo que vale para qualquer linguagem.
A regra que sustenta tudo: o model não sabe que existe HTML. Um método de
model devolve array ou objeto, nunca string com <table>. É essa separação
que permite trocar o banco por uma implementação em memória e testar a lógica
sem subir servidor nenhum.
Onde cada peça fica
| Camada | Responde por | Trabalha com | Nunca deve |
|---|---|---|---|
model | dado e regra | PDO, array | gerar HTML |
view | aparência | dado já pronto | consultar banco |
controller | o que fazer | model e view | montar HTML na mão |
O front controller é o único arquivo acessível pela web: ele lê a URL, casa
com a tabela de rotas e entrega o controle. O resto do projeto fica em src/,
fora da pasta public/, e não pode ser acessado por URL.
Interface no model e injeção no controller
A assinatura do model é o contrato. O controller recebe a interface no
construtor, e cada implementação (banco de verdade ou array em memória) serve
ao mesmo código.
interface AlunoRepositoryInterface { public function listar(): array; public function buscar(int $id): ?array; } final class AlunoMemoria implements AlunoRepositoryInterface { public function __construct(private array $dados) {} public function listar(): array { return $this->dados; } public function buscar(int $id): ?array { foreach ($this->dados as $aluno) { if ($aluno['id'] === $id) { return $aluno; } } return null; } } final class AlunoController { public function __construct(private AlunoRepositoryInterface $repo) {} public function index(): string { return view_alunos($this->repo->listar()); } }
Nada no AlunoController muda quando o model passa de AlunoMemoria para a
classe com PDO. É esse o teste prático de uma boa separação: se a troca
obrigou a mexer na view, as camadas ainda estão acopladas.
Exemplo
<?php // ============================================================ // DIA 17 - AULA 1: as tres camadas do MVC e o papel de cada uma // ============================================================ // O exemplo roda inteiro na linha de comando. O "banco" e um array em // memoria: o foco aqui e a separacao de papeis, nao o SQL. declare(strict_types=1); // ---------- MODEL: dado e regra de negocio ---------- // O model nao sabe que existe HTML. Ele devolve dado e nada mais. final class AlunoRepository { /** @param list<array{id:int,nome:string,email:string,nota:float}> $dados */ public function __construct(private array $dados) { } /** @return list<array{id:int,nome:string,email:string,nota:float}> */ public function listar(): array { return $this->dados; } /** @return array{id:int,nome:string,email:string,nota:float}|null */ public function buscar(int $id): ?array { foreach ($this->dados as $aluno) { if ($aluno['id'] === $id) { return $aluno; } } return null; } /** @return list<array{id:int,nome:string,email:string,nota:float}> */ public function aprovados(float $corte = 7.0): array { return array_values(array_filter( $this->dados, fn (array $aluno): bool => $aluno['nota'] >= $corte )); } } // ---------- VIEW: HTML, e nada mais ---------- // A view recebe o que o model devolve e monta a saida. Ela nao consulta // o banco e nao decide o que acontece: isso e do controller. final class AlunoView { /** @param list<array{id:int,nome:string,email:string,nota:float}> $alunos */ public function listagem(array $alunos): string { $linhas = ''; foreach ($alunos as $aluno) { $linhas .= sprintf( " <tr><td>%d</td><td>%s</td><td>%s</td><td>%.2f</td></tr>\n", $aluno['id'], self::escapar($aluno['nome']), self::escapar($aluno['email']), $aluno['nota'] ); } return "<table class=\"alunos\">\n" . " <tr><th>Id</th><th>Nome</th><th>E-mail</th><th>Nota</th></tr>\n" . $linhas . "</table>\n"; } /** Escapa na saida: a view nao gera HTML a partir de dado cru. */ public static function escapar(mixed $valor): string { return htmlspecialchars((string) $valor, ENT_QUOTES, 'UTF-8'); } } // ---------- CONTROLLER: o meio-campo ---------- // O controller e o unico que conhece as tres pecas. Ele monta a resposta. final class AlunoController { public function __construct( private AlunoRepository $repo, private AlunoView $view ) { } public function index(): string { return $this->view->listagem($this->repo->listar()); } public function aprovados(): string { return $this->view->listagem($this->repo->aprovados()); } } // ---------- O programa monta as pecas e chama ---------- $repo = new AlunoRepository([ ['id' => 1, 'nome' => 'Ana Souza', 'email' => '[email protected]', 'nota' => 8.5], ['id' => 2, 'nome' => 'Bruno Lima', 'email' => '[email protected]', 'nota' => 5.0], ['id' => 3, 'nome' => 'Carla Dias', 'email' => '[email protected]', 'nota' => 9.0], ]); $controller = new AlunoController($repo, new AlunoView()); // O model devolve dado puro: e um array, igual ao que o PDO devolveria. echo "1. O MODEL devolve array, sem nenhuma marca de HTML:\n"; print_r($repo->buscar(2)); echo "\n"; // O controller nao monta HTML: ele entrega para a view. echo "2. O CONTROLLER repassa o resultado para a view:\n"; echo " action index() -> " . strlen($controller->index()) . " caracteres de HTML\n"; echo " action aprovados() -> " . strlen($controller->aprovados()) . " caracteres de HTML\n\n"; // A view e a unica que escreve a saida final. echo "3. A VIEW escreve o HTML (controller action index):\n"; echo $controller->index() . "\n"; // ---------- Trocar o model sem tocar no resto ---------- // Este e o ganho concreto da separacao: o controller e a view ficam // identicos; so a dependencia entregue a eles muda. $outroRepo = new AlunoRepository(array_slice($repo->listar(), 0, 2)); $outroController = new AlunoController($outroRepo, new AlunoView()); echo "4. Mesmo controller, outro model (2 alunos em vez de 3):\n"; echo $outroController->index() . "\n"; echo "5. Cada camada so conversa com o tipo que espera:\n"; printf( " repo e AlunoRepository? %s\n view e AlunoView? %s\n controller e AlunoController? %s\n", $outroRepo instanceof AlunoRepository ? 'sim' : 'nao', (new AlunoView()) instanceof AlunoView ? 'sim' : 'nao', $outroController instanceof AlunoController ? 'sim' : 'nao' );
Saída real
1. O MODEL devolve array, sem nenhuma marca de HTML:
Array
(
[id] => 2
[nome] => Bruno Lima
[email] => [email protected]
[nota] => 5
)
2. O CONTROLLER repassa o resultado para a view:
action index() -> 339 caracteres de HTML
action aprovados() -> 257 caracteres de HTML
3. A VIEW escreve o HTML (controller action index):
<table class="alunos">
<tr><th>Id</th><th>Nome</th><th>E-mail</th><th>Nota</th></tr>
<tr><td>1</td><td>Ana Souza</td><td>[email protected]</td><td>8.50</td></tr>
<tr><td>2</td><td>Bruno Lima</td><td>[email protected]</td><td>5.00</td></tr>
<tr><td>3</td><td>Carla Dias</td><td>[email protected]</td><td>9.00</td></tr>
</table>
4. Mesmo controller, outro model (2 alunos em vez de 3):
<table class="alunos">
<tr><th>Id</th><th>Nome</th><th>E-mail</th><th>Nota</th></tr>
<tr><td>1</td><td>Ana Souza</td><td>[email protected]</td><td>8.50</td></tr>
<tr><td>2</td><td>Bruno Lima</td><td>[email protected]</td><td>5.00</td></tr>
</table>
5. Cada camada so conversa com o tipo que espera:
repo e AlunoRepository? sim
view e AlunoView? sim
controller e AlunoController? sim
Rotas e controller
A rota é o contrato entre a URL e o código: diz qual controller e qual
método respondem a cada endereço. O PHP não faz isso sozinho — sem um
roteador, cada URL aponta para um arquivo físico e não há id dinâmico na
endereço.
A URL tem duas partes, e elas têm regras diferentes. O path (/alunos/42)
identifica o recurso e casa com a rota; a query string
(?ordem=nome) é filtro, e vai para $_GET sem participar do roteamento.
Da URL ao método do controller
A tabela de rotas e o despacho
A tabela é um array: a chave é o verbo mais o caminho, o valor é a ação. O
roteador casa a URL, e o trecho numérico vira argumento tipado do método.
$rotas = new Roteador([ 'GET /' => ['AlunoController', 'index'], 'GET /alunos' => ['AlunoController', 'index'], 'GET /alunos/novo' => ['AlunoController', 'novo'], 'GET /alunos/{id}' => ['AlunoController', 'editar'], 'POST /alunos' => ['AlunoController', 'salvar'], 'GET /alunos/{id}/excluir' => ['AlunoController', 'excluir'], ]); [$classe, $acao, $params] = $rotas->despachar('GET', '/alunos/42'); // $classe = 'AlunoController', $acao = 'editar', $params = [42]
/alunos/abc não casa com /alunos/{id}: o parâmetro numérico se
converte para int na passagem, e uma letra nesse lugar é 404, não erro 500.
O .htaccess que entrega tudo ao mesmo arquivo
O servidor precisa saber que /alunos/42 deve cair no index.php. É o
mod_rewrite:
RewriteEngine On RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule ^ index.php [QSA,L]
!-f e !-d deixam passar o que já existe em disco (favicon, CSS, imagem).
O resto vai para o index.php, que lê $_SERVER['REQUEST_METHOD'] e
$_SERVER['REQUEST_URI'] e chama o roteador.
Redirecionar depois de gravar
Regra do CRUD: toda gravação termina em redirecionamento, nunca em echo do
formulário. Sem isso, o F5 reenvia o POST e grava o mesmo aluno de novo.
public function salvar(): void { // valida, grava no model $id = $this->model->cadastrar($_POST); http_response_code(302); header('Location: /alunos/' . $id); exit; // sem o exit o PHP ainda manda o corpo antigo }
O exit vem logo depois do header. O header() só funciona enquanto nenhum
byte foi enviado: se algum echo ou aviso aconteceu antes, o PHP ignora o
redirecionamento.
Exemplo
<?php // ============================================================ // DIA 17 - AULA 2: roteador e controller // ============================================================ // O roteador e um array: "METODO /rota" => [classe, metodo]. Nao ha // servidor web aqui: a URL chega pronta em $uri. declare(strict_types=1); // ---------- Redirect: demonstrado antes de qualquer echo ---------- // header() so funciona enquanto nenhum byte foi enviado. No script de // linha de comando a demonstracao precisa vir primeiro, senao o PHP // avisa "headers already sent". $redirect = function (): void { http_response_code(302); header('Location: /produtos'); exit; }; // A funcao acima nao e chamada aqui: chamar sairia do script. O que // interessa e a ordem das linhas e o exit logo depois do header. // ---------- Controller: um metodo por acao ---------- final class ProdutoController { public function index(): string { return 'Lista de produtos (3 itens)'; } public function novo(): string { return 'Formulario de cadastro'; } public function ver(int $id): string { return "Formulario de edicao do produto #$id"; } public function salvar(): string { return 'Produto salvo'; } } // ---------- Roteador ---------- final class Roteador { /** * @param array<string, array{0:string,1:string}> $rotas * "GET /produtos" => ['ProdutoController', 'index'] */ public function __construct(private array $rotas) { } /** * Casa a URI com uma rota e devolve [classe, metodo, parametros]. * O trecho numerico da URL vira argumento tipado do metodo. * * @return array{0:string,1:string,2:list<int>} */ public function despachar(string $metodoHttp, string $uri): array { $caminho = rtrim((string) parse_url($uri, PHP_URL_PATH), '/'); if ($caminho === '') { $caminho = '/'; } // 1. Rota exata: 'GET /produtos' $chave = $metodoHttp . ' ' . $caminho; if (isset($this->rotas[$chave])) { [$classe, $acao] = $this->rotas[$chave]; return [$classe, $acao, []]; } // 2. Rota com parametro: 'GET /produtos/{id}' foreach ($this->rotas as $rota => [$classe, $acao]) { [$verbo, $padrao] = explode(' ', $rota, 2); if ($verbo !== $metodoHttp) { continue; } $partesPadrao = array_values(array_filter(explode('/', $padrao), 'strlen')); $partesUri = array_values(array_filter(explode('/', $caminho), 'strlen')); if (count($partesPadrao) !== count($partesUri)) { continue; } $parametros = []; $casou = true; foreach ($partesPadrao as $i => $parte) { if (preg_match('/^\{(\w+)\}$/', $parte) === 1) { // So numero vira parametro: /produtos/abc nao casa. if (ctype_digit($partesUri[$i]) === false) { $casou = false; break; } $parametros[] = (int) $partesUri[$i]; continue; } if ($parte !== $partesUri[$i]) { $casou = false; break; } } if ($casou) { return [$classe, $acao, $parametros]; } } return ['', '', []]; } } // ---------- Tabela de rotas ---------- $rotas = new Roteador([ 'GET /' => ['ProdutoController', 'index'], 'GET /produtos' => ['ProdutoController', 'index'], 'GET /produtos/novo' => ['ProdutoController', 'novo'], 'GET /produtos/{id}' => ['ProdutoController', 'ver'], 'POST /produtos' => ['ProdutoController', 'salvar'], ]); // ---------- As URLs que chegam no roteador ---------- $requisicoes = [ ['GET', '/'], ['GET', '/produtos'], ['GET', '/produtos/novo'], ['GET', '/produtos/42'], ['GET', '/produtos/abc'], // id invalido: nao casa ['POST', '/produtos'], ['GET', '/inexistente'], ]; $controller = new ProdutoController(); echo "1. URL -> metodo do controller (GET /produtos/42 -> ver(42)):\n"; foreach ($requisicoes as [$verbo, $uri]) { [$classe, $acao, $params] = $rotas->despachar($verbo, $uri); if ($acao === '') { printf(" %-4s %-16s -> 404 (nenhuma rota casa)\n", $verbo, $uri); continue; } $argumentos = $params === [] ? '' : '(' . implode(', ', $params) . ')'; printf(" %-4s %-16s -> %s::%s%s\n", $verbo, $uri, $classe, $acao, $argumentos); } echo "\n2. Chamada de verdade, com o parametro tipado como int:\n"; [$classe, $acao, $params] = $rotas->despachar('GET', '/produtos/42'); printf( " %s->%s(%s %d): %s\n", $classe, $acao, get_debug_type($params[0]), $params[0], $controller->{$acao}(...$params) ); // ---------- Query string: o outro lado da URL ---------- echo "\n3. A query string nao interfere na rota (os filtros ficam em \$_GET):\n"; $uri = '/produtos?ordem=nome&limite=10'; $query = []; parse_str((string) parse_url($uri, PHP_URL_QUERY), $query); [$classe, $acao] = $rotas->despachar('GET', $uri); printf( " %s casa com %s::%s | filtros: ordem=%s limite=%s\n", $uri, $classe, $acao, $query['ordem'], $query['limite'] ); // ---------- Redirecionar: header Location + exit ---------- // A funcao $redirect, definida no topo, e o padrao do "Post/Redirect/Get": // grava, manda Location: /alunos e sai. O browser segue para a listagem // e o F5 nao repete o INSERT. echo "\n4. Apos gravar, o controller redireciona:\n"; echo " http_response_code(302);\n"; echo " header('Location: /produtos');\n"; echo " exit;\n"; echo " O exit vem logo depois: sem ele o PHP ainda manda o corpo antigo.\n"; // ---------- Path info no servidor real ---------- echo "\n5. No servidor de verdade a URL chega em \$_SERVER:\n"; $exemplo = ['REQUEST_METHOD' => 'GET', 'REQUEST_URI' => '/alunos/42/editar']; $pathInfo = rtrim((string) parse_url($exemplo['REQUEST_URI'], PHP_URL_PATH), '/'); printf( " REQUEST_METHOD=%s REQUEST_URI=%s -> '%s %s'\n", $exemplo['REQUEST_METHOD'], $exemplo['REQUEST_URI'], $exemplo['REQUEST_METHOD'], $pathInfo );
Saída real
1. URL -> metodo do controller (GET /produtos/42 -> ver(42)):
GET / -> ProdutoController::index
GET /produtos -> ProdutoController::index
GET /produtos/novo -> ProdutoController::novo
GET /produtos/42 -> ProdutoController::ver(42)
GET /produtos/abc -> 404 (nenhuma rota casa)
POST /produtos -> ProdutoController::salvar
GET /inexistente -> 404 (nenhuma rota casa)
2. Chamada de verdade, com o parametro tipado como int:
ProdutoController->ver(int 42): Formulario de edicao do produto #42
3. A query string nao interfere na rota (os filtros ficam em $_GET):
/produtos?ordem=nome&limite=10 casa com ProdutoController::index | filtros: ordem=nome limite=10
4. Apos gravar, o controller redireciona:
http_response_code(302);
header('Location: /produtos');
exit;
O exit vem logo depois: sem ele o PHP ainda manda o corpo antigo.
5. No servidor de verdade a URL chega em $_SERVER:
REQUEST_METHOD=GET REQUEST_URI=/alunos/42/editar -> 'GET /alunos/42/editar'
Model, view e templates
Model e view conversam por dado, nunca por HTML. O model devolve objetos ou
array; a view recebe isso pronto e devolve a string final. O template é
justamente essa string com os buracos preenchidos — e o mesmo arquivo pode ser
chamado de view, de template ou de render.
O que separa um template de um monte de echo é o escape na saída: todo
dado que veio do banco passa por htmlspecialchars antes de entrar no HTML.
Sem isso, um aluno chamado <script> executa no navegador de quem abriu a
listagem.
Do model à string HTML
A view é uma função que devolve string
A assinatura mais simples de view possível: recebe dado, devolve HTML. A regra
de negócio fica no model — a view não pergunta "nota 7 é aprovado?", pergunta
$aluno->aprovado() e só usa a resposta para escolher a classe CSS.
function view_alunos(array $alunos, string $titulo): string { $corpo = ''; foreach ($alunos as $aluno) { $corpo .= sprintf( " <tr class=\"%s\"><td>%d</td><td>%s</td><td>%.2f</td></tr>\n", $aluno->aprovado() ? 'ok' : 'ruim', $aluno->id, htmlspecialchars($aluno->nome, ENT_QUOTES, 'UTF-8'), $aluno->nota ); } return "<h1>{$titulo}</h1>\n<table>\n{$corpo}</table>\n"; }
Mudar o corte de aprovação mexe no model e não na view. Se a lógica de "aprovado"
estiver escrita dentro do template, a view deixou de ser view.
Template em arquivo e layout por cima
Quando a view cresce, ela sai da função e vira um .php com <?php ?> e
<?= ?>. O include imprime, o ob_start()/ob_get_clean() captura esse
resultado como string, e o layout o envolve com <head>, menu e rodapé.
// controller $alunos = $this->model->listar(); ob_start(); include __DIR__ . '/../views/alunos/lista.php'; $conteudo = ob_get_clean(); echo $this->layout('Lista de alunos', $conteudo);
// controller, o metodo que envolve tudo private function layout(string $titulo, string $conteudo): string { return '<!DOCTYPE html><html lang="pt-BR"><head><meta charset="utf-8">' . "<title>{$titulo}</title></head><body>{$conteudo}</body></html>"; }
<?php foreach ($alunos as $aluno): ?> <tr> <td><?= $aluno->id ?></td> <td><?= htmlspecialchars($aluno->nome, ENT_QUOTES, 'UTF-8') ?></td> <td><?= number_format($aluno->nota, 2, ',', '.') ?></td> </tr> <?php endforeach; ?>
O <?= ?> é atalho de <?php echo ?>. Ele não escapa sozinho: o
htmlspecialchars explícito continua obrigatório em todo dado vindo do banco.
Twig quando o HTML ficar grande
Template com lógica vira gargalo quando cresce. O Twig entrega interpolação
com escape automático ({{ aluno.nome }} já sai escapado), {% for %} sem
misturar com PHP e cache do template compilado. Instala com
composer require twig/twig e o controller chama
$twig->render('alunos/lista.php', ['alunos' => $alunos]).
Enquanto o projeto for pequeno, view em PHP com htmlspecialchars explícito
dá conta do mesmo serviço sem dependência. O ganho do Twig aparece quando
mesma view é reaproveitada por outra equipe ou por outra linguagem.
Exemplo
<?php // ============================================================ // DIA 17 - AULA 3: model, view e template // ============================================================ // O model e uma classe com os dados; a view e um template .php guardado // numa string (aqui em heredoc, sem gravar arquivo em disco) e um layout // que envolve o conteudo. declare(strict_types=1); // ---------- MODEL: os dados e as regras ---------- final class Aluno { public function __construct( public readonly int $id, public readonly string $nome, public readonly float $nota ) { } /** Regra de negocio: fica no model, nunca na view. */ public function aprovado(): bool { return $this->nota >= 7.0; } public function situacao(): string { return match (true) { $this->nota >= 9.0 => 'Excelente', $this->nota >= 7.0 => 'Aprovado', $this->nota >= 5.0 => 'Recuperacao', default => 'Reprovado', }; } } final class AlunoRepository { /** @var list<Aluno> */ private array $alunos; /** @param list<Aluno> $alunos */ public function __construct(array $alunos) { $this->alunos = $alunos; } /** @return list<Aluno> */ public function todos(): array { return $this->alunos; } } // ---------- VIEW: funcao curta que monta e entrega a string ---------- // A view recebe dado pronto e devolve HTML. Ela nao instancia model e // nao abre conexao: quem traz o dado e o controller. function view_alunos(array $alunos, string $titulo): string { $corpo = ''; foreach ($alunos as $aluno) { // Escape na saida: dado que vem do banco nunca entra cru no HTML. $corpo .= sprintf( " <tr class=\"%s\"><td>%d</td><td>%s</td><td>%.2f</td><td>%s</td></tr>\n", $aluno->aprovado() ? 'ok' : 'ruim', $aluno->id, htmlspecialchars($aluno->nome, ENT_QUOTES, 'UTF-8'), $aluno->nota, $aluno->situacao() ); } $total = count($alunos); // O template: cabecalho, o que o la montou, e o rodape da tabela. return "<h1>{$titulo}</h1>\n" . "<p class=\"total\">{$total} aluno(s) cadastrado(s)</p>\n" . "<table>\n" . " <tr><th>Id</th><th>Nome</th><th>Nota</th><th>Situacao</th></tr>\n" . "{$corpo}" . "</table>\n"; } // ---------- LAYOUT: envolve o conteudo sem saber o que ele e ---------- // Em arquivo, o layout e um .php a parte e o template entra por // include; a variavel $conteudo e o que o include deixou na memoria. function render(string $titulo, string $conteudo): string { return <<<HTML <!DOCTYPE html> <html lang="pt-BR"> <head> <meta charset="utf-8"> <title>{$titulo}</title> <style> body { font-family: sans-serif; } tr.ok { background: #eefaf0; } tr.ruim { background: #fdeef0; } </style> </head> <body> {$conteudo}</body> </html> HTML; } // ---------- O controller junta as tres pecas ---------- $repo = new AlunoRepository([ new Aluno(1, 'Ana Souza', 8.5), new Aluno(2, 'Bruno Lima', 5.0), new Aluno(3, 'Carla Dias', 9.0), ]); $alunos = $repo->todos(); echo "1. O MODEL devolve objetos com metodo, nao array cru:\n"; foreach ($alunos as $aluno) { printf( " #%d %-12s %.2f aprovado? %-3s situacao: %s\n", $aluno->id, $aluno->nome, $aluno->nota, $aluno->aprovado() ? 'sim' : 'nao', $aluno->situacao() ); } echo "\n2. A VIEW monta o template:\n"; echo view_alunos($alunos, 'Alunos') . "\n"; echo "\n3. O LAYOUT envolve o template:\n"; echo render('Lista de alunos', view_alunos($alunos, 'Alunos')) . "\n"; echo "\n4. Escape na view: nome com tag vira texto, nao HTML.\n"; $hostil = [new Aluno(4, '<script>alert(1)</script>', 9.9)]; echo view_alunos($hostil, 'Teste de escape') . "\n"; // ---------- Variaveis que a view espera: extract ---------- // extract() transforma as variaveis do controller em variaveis do // template. E o que o Twig faz por baixo, com nome proprio. function view_com_extract(array $alunos, string $titulo): string { extract(['alunos' => $alunos, 'titulo' => $titulo], EXTR_SKIP); $linhas = ''; foreach ($alunos as $aluno) { $linhas .= " <li>{$aluno->nome} ({$aluno->nota})</li>\n"; } return "<ul>\n{$linhas}</ul>\n"; } echo "\n5. extract() no lugar do array na assinatura da view:\n"; echo view_com_extract($alunos, 'Alunos') . "\n";
Saída real
1. O MODEL devolve objetos com metodo, nao array cru:
#1 Ana Souza 8.50 aprovado? sim situacao: Aprovado
#2 Bruno Lima 5.00 aprovado? nao situacao: Recuperacao
#3 Carla Dias 9.00 aprovado? sim situacao: Excelente
2. A VIEW monta o template:
<h1>Alunos</h1>
<p class="total">3 aluno(s) cadastrado(s)</p>
<table>
<tr><th>Id</th><th>Nome</th><th>Nota</th><th>Situacao</th></tr>
<tr class="ok"><td>1</td><td>Ana Souza</td><td>8.50</td><td>Aprovado</td></tr>
<tr class="ruim"><td>2</td><td>Bruno Lima</td><td>5.00</td><td>Recuperacao</td></tr>
<tr class="ok"><td>3</td><td>Carla Dias</td><td>9.00</td><td>Excelente</td></tr>
</table>
3. O LAYOUT envolve o template:
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<title>Lista de alunos</title>
<style>
body { font-family: sans-serif; }
tr.ok { background: #eefaf0; }
tr.ruim { background: #fdeef0; }
</style>
</head>
<body>
<h1>Alunos</h1>
<p class="total">3 aluno(s) cadastrado(s)</p>
<table>
<tr><th>Id</th><th>Nome</th><th>Nota</th><th>Situacao</th></tr>
<tr class="ok"><td>1</td><td>Ana Souza</td><td>8.50</td><td>Aprovado</td></tr>
<tr class="ruim"><td>2</td><td>Bruno Lima</td><td>5.00</td><td>Recuperacao</td></tr>
<tr class="ok"><td>3</td><td>Carla Dias</td><td>9.00</td><td>Excelente</td></tr>
</table>
</body>
</html>
4. Escape na view: nome com tag vira texto, nao HTML.
<h1>Teste de escape</h1>
<p class="total">1 aluno(s) cadastrado(s)</p>
<table>
<tr><th>Id</th><th>Nome</th><th>Nota</th><th>Situacao</th></tr>
<tr class="ok"><td>4</td><td><script>alert(1)</script></td><td>9.90</td><td>Excelente</td></tr>
</table>
5. extract() no lugar do array na assinatura da view:
<ul>
<li>Ana Souza (8.5)</li>
<li>Bruno Lima (5)</li>
<li>Carla Dias (9)</li>
</ul>
Projeto final
O projeto final é um cadastro de alunos que usa tudo que o curso já mostrou:
tabela no MySQL, PDO com prepared statement, classe de model com CRUD,
controller com uma ação por rota, formulários com validação e listagem em
tabela. É o passo que mostra as peças se encaixando, não uma novidade.
A ordem abaixo é a ordem de construção: cada passo só depende dos
anteriores, e o projeto já roda ao fim de cada um.
Os seis passos
- Banco e tabela.
idAUTO_INCREMENTeemailUNIQUE: dois alunos
não podem ter o mesmo e-mail, e o banco recusa o duplicado sozinho.
CREATE DATABASE IF NOT EXISTS escola CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE escola; CREATE TABLE alunos ( id INT AUTO_INCREMENT PRIMARY KEY, nome VARCHAR(120) NOT NULL, email VARCHAR(160) NOT NULL UNIQUE, nota DECIMAL(4,2) NOT NULL DEFAULT 0.00, criado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_alunos_nome (nome) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
- Classe de conexão. Um método estático que monta o
DSNe devolve o
PDO pronto, com ERRMODE_EXCEPTION e FETCH_ASSOC. A senha vem de
getenv(), nunca do código.
final class Conexao { public static function abrir(): PDO { $dsn = sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', getenv('DB_HOST'), getenv('DB_PORT') ?: 3306, getenv('DB_NAME')); return new PDO($dsn, getenv('DB_USER'), getenv('DB_PASS'), [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ]); } }
- Model com o CRUD. Uma classe que recebe o
PDOno construtor e tem
listar(), buscar(), cadastrar(), atualizar() e excluir(). Os
métodos recebem e devolvem array; nenhum deles monta HTML.
final class Aluno { public function __construct(private PDO $pdo) {} public function cadastrar(array $dados): int { $stmt = $this->pdo->prepare( 'INSERT INTO alunos (nome, email, nota) VALUES (:nome, :email, :nota)' ); $stmt->execute([ ':nome' => trim($dados['nome']), ':email' => trim($dados['email']), ':nota' => (float) $dados['nota'], ]); return (int) $this->pdo->lastInsertId(); } }
- Controller com uma ação por rota.
index(),novo(),
editar(int $id), salvar() e excluir(int $id). A validação do
formulário é dele; o model só grava. salvar() e excluir() terminam em
header('Location: /alunos') seguido de exit.
- Views. Uma de listagem (tabela com
id, nome, e-mail, nota e os links
de editar e excluir) e uma de formulário reaproveitada por "novo" e
"editar". Todo dado passa por htmlspecialchars.
<?php foreach ($alunos as $aluno): ?> <tr> <td><?= $aluno['id'] ?></td> <td><?= htmlspecialchars($aluno['nome'], ENT_QUOTES, 'UTF-8') ?></td> <td><?= number_format((float) $aluno['nota'], 2, ',', '.') ?></td> </tr> <?php endforeach; ?>
- Rotas e front controller. A tabela liga as URLs às ações, e o
public/index.php lê REQUEST_METHOD e REQUEST_URI, despacha e imprime
o retorno. O .htaccess manda todo o resto para esse arquivo.
$rotas = [ 'GET /alunos' => 'index', 'GET /alunos/novo' => 'novo', 'POST /alunos' => 'salvar', 'GET /alunos/{id}' => 'editar', 'POST /alunos/{id}' => 'salvar', 'GET /alunos/{id}/excluir' => 'excluir', ];
A estrutura de pastas
public/ index.php (front controller) e .htaccess src/Controller/ AlunoController.php src/Model/ Aluno.php src/Database/ Conexao.php views/alunos/ lista.php e form.php config/ config.php (lê o .env) sql/ schema.sql
Só public/ é acessível pela web. src/, config/ e .env ficam fora, e é
isso que impede o navegador de baixar a senha do banco em código-fonte.
O que conferir antes de considerar pronto
- Cadastrar, editar e excluir pela interface, sem erro no console.
- E-mail duplicado: o banco recusa e a tela mostra a mensagem do
PDOException. - F5 depois de salvar não duplica o registro (é o
header+exit). - Nenhum dado do banco entra na view sem
htmlspecialchars. $_POSTnunca concatenado no SQL: sempre prepared statement.
Exemplo
<?php // ============================================================ // DIA 17 - AULA 4: projeto final de cadastro de alunos // ============================================================ // O projeto tem seis pecas: tabela, conexao, model, controller, views // e rotas. Este arquivo NAO e o projeto: e a montagem das pecas na // ordem em que elas se conectam. O SQL vai em heredoc, sem tentar // conectar ao banco. declare(strict_types=1); // ============================================================ // 1. O SQL do projeto // ============================================================ $sql = <<<'SQL' CREATE DATABASE IF NOT EXISTS escola CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE escola; CREATE TABLE IF NOT EXISTS alunos ( id INT AUTO_INCREMENT PRIMARY KEY, nome VARCHAR(120) NOT NULL, email VARCHAR(160) NOT NULL UNIQUE, nota DECIMAL(4,2) NOT NULL DEFAULT 0.00, criado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_alunos_nome (nome) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; INSERT INTO alunos (nome, email, nota) VALUES ('Ana Souza', '[email protected]', 8.50), ('Bruno Lima', '[email protected]', 5.00), ('Carla Dias', '[email protected]', 9.00); SQL; echo "=== 1. SQL do projeto (rode no MySQL antes do PHP) ===\n"; echo $sql . "\n"; // ============================================================ // 2. A classe de conexao // ============================================================ // A senha vem do ambiente. No repo, essa leitura fica numa classe // propria; aqui o SQL e a assinatura bastam. final class Conexao { public static function abrir(): PDO { $dsn = sprintf( 'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', getenv('DB_HOST') ?: '127.0.0.1', getenv('DB_PORT') ?: '3306', getenv('DB_NAME') ?: 'escola' ); return new PDO($dsn, getenv('DB_USER') ?: 'app_user', getenv('DB_PASS') ?: '', [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ]); } } // ============================================================ // 3. O model: CRUD da tabela alunos // ============================================================ // Os metodos recebem e devolvem array, e nao HTML nem objeto de tela. final class Aluno { public function __construct(private PDO $pdo) { } /** @return list<array{id:int,nome:string,email:string,nota:string}> */ public function listar(string $ordem = 'nome'): array { // A ordem vem de uma lista branca: nunca entra direto no SQL. $colunas = ['nome', 'email', 'nota', 'criado_em']; $ordem = in_array($ordem, $colunas, true) ? $ordem : 'nome'; $sql = "SELECT id, nome, email, nota, criado_em FROM alunos ORDER BY {$ordem}"; $stmt = $this->pdo->query($sql); return $stmt->fetchAll(); } public function buscar(int $id): ?array { $stmt = $this->pdo->prepare( 'SELECT id, nome, email, nota FROM alunos WHERE id = :id' ); $stmt->execute([':id' => $id]); $aluno = $stmt->fetch(); return $aluno === false ? null : $aluno; } public function cadastrar(array $dados): int { $stmt = $this->pdo->prepare( 'INSERT INTO alunos (nome, email, nota) VALUES (:nome, :email, :nota)' ); $stmt->execute([ ':nome' => trim($dados['nome']), ':email' => trim($dados['email']), ':nota' => (float) $dados['nota'], ]); return (int) $this->pdo->lastInsertId(); } public function atualizar(int $id, array $dados): bool { $stmt = $this->pdo->prepare( 'UPDATE alunos SET nome = :nome, email = :email, nota = :nota WHERE id = :id' ); return $stmt->execute([ ':nome' => trim($dados['nome']), ':email' => trim($dados['email']), ':nota' => (float) $dados['nota'], ':id' => $id, ]) && $stmt->rowCount() >= 0; } public function excluir(int $id): bool { $stmt = $this->pdo->prepare('DELETE FROM alunos WHERE id = :id'); return $stmt->execute([':id' => $id]); } } // ============================================================ // 4. O controller: uma acao por rota // ============================================================ final class AlunoController { public function __construct(private Aluno $model) { } public function index(): string { $alunos = $this->model->listar('nome'); $linhas = ''; foreach ($alunos as $a) { $linhas .= sprintf( " <tr><td>%d</td><td>%s</td><td>%s</td><td>%s</td>" . "<td><a href=\"/alunos/%d/editar\">editar</a>" . " <a href=\"/alunos/%d/excluir\">excluir</a></td></tr>\n", $a['id'], htmlspecialchars($a['nome'], ENT_QUOTES, 'UTF-8'), htmlspecialchars($a['email'], ENT_QUOTES, 'UTF-8'), number_format((float) $a['nota'], 2, ',', '.'), $a['id'], $a['id'] ); } return "<h1>Alunos cadastrados</h1>\n" . "<p><a href=\"/alunos/novo\">Cadastrar aluno</a></p>\n" . "<table>\n <tr><th>Id</th><th>Nome</th><th>E-mail</th><th>Nota</th>" . "<th>Acoes</th></tr>\n{$linhas}</table>\n"; } public function novo(?array $erros = [], array $valores = []): string { $acao = htmlspecialchars((string) ($erros['_acao'] ?? '/alunos'), ENT_QUOTES, 'UTF-8'); $msg = $erros === [] ? '' : '<p class="erro">' . htmlspecialchars((string) ($erros['_mensagem'] ?? ''), ENT_QUOTES, 'UTF-8') . '</p>'; $nome = htmlspecialchars((string) ($valores['nome'] ?? ''), ENT_QUOTES, 'UTF-8'); $email = htmlspecialchars((string) ($valores['email'] ?? ''), ENT_QUOTES, 'UTF-8'); $nota = htmlspecialchars((string) ($valores['nota'] ?? ''), ENT_QUOTES, 'UTF-8'); return "<h1>Cadastrar aluno</h1>\n{$msg}\n" . "<form method=\"post\" action=\"{$acao}\">\n" . " <label>Nome <input type=\"text\" name=\"nome\" value=\"{$nome}\" required></label>\n" . " <label>E-mail <input type=\"email\" name=\"email\" value=\"{$email}\" required></label>\n" . " <label>Nota <input type=\"number\" name=\"nota\" value=\"{$nota}\" step=\"0.01\" min=\"0\" max=\"10\"></label>\n" . " <button type=\"submit\">Gravar</button>\n" . "</form>\n"; } public function editar(int $id): string { $aluno = $this->model->buscar($id); if ($aluno === null) { return "<h1>Aluno nao encontrado</h1>\n"; } return $this->novo(['_acao' => "/alunos/{$id}"], $aluno); } public function salvar(): string { $dados = [ 'nome' => (string) ($_POST['nome'] ?? ''), 'email' => (string) ($_POST['email'] ?? ''), 'nota' => (string) ($_POST['nota'] ?? '0'), ]; // Validacao no controller: o model grava, nao valida formulario. $erros = []; if (mb_strlen($dados['nome']) < 3) { $erros['_mensagem'] = 'Nome precisa de ao menos 3 caracteres.'; } elseif (filter_var($dados['email'], FILTER_VALIDATE_EMAIL) === false) { $erros['_mensagem'] = 'E-mail invalido.'; } elseif ((float) $dados['nota'] < 0 || (float) $dados['nota'] > 10) { $erros['_mensagem'] = 'Nota deve estar entre 0 e 10.'; } if ($erros !== []) { return $this->novo($erros + ['_acao' => '/alunos'], $dados); } $id = isset($_POST['id']) && $_POST['id'] !== '' ? $this->model->atualizar((int) $_POST['id'], $dados) : $this->model->cadastrar($dados); // PRG: depois de gravar, redireciona. Sem isso, recarregar a pagina // gravaria o mesmo aluno de novo. header('Location: /alunos/' . (is_int($id) ? $id : (int) $_POST['id'])); exit; } public function excluir(int $id): string { $this->model->excluir($id); header('Location: /alunos'); exit; } } // ============================================================ // 5. A view: o mesmo formulario em duas telas // ============================================================ // Novo e editar chamam a mesma funcao mudando a action do form e o id // escondido. E o que evita dois formularios quase iguais. $formulario = function (string $acao, array $valores = [], array $erros = []): string { $msg = $erros === [] ? '' : '<p class="erro">' . htmlspecialchars((string) $erros['_mensagem'], ENT_QUOTES, 'UTF-8') . '</p>'; $id = (int) ($valores['id'] ?? 0); return <<<HTML <form method="post" action="{$acao}"> <input type="hidden" name="id" value="{$id}"> <label>Nome <input type="text" name="nome" required></label> <label>E-mail <input type="email" name="email" required></label> <label>Nota <input type="number" name="nota" step="0.01" min="0" max="10"></label> <button type="submit">Gravar</button> </form> {$msg} HTML; }; echo "=== 2. View do formulario (novo e editar usam a mesma) ===\n"; echo $formulario('/alunos') . "\n"; // ============================================================ // 6. As rotas do projeto // ============================================================ $rotas = [ 'GET /alunos' => 'index', 'GET /alunos/novo' => 'novo', 'POST /alunos' => 'salvar', 'GET /alunos/{id}' => 'editar', 'POST /alunos/{id}' => 'salvar', 'GET /alunos/{id}/excluir' => 'excluir', ]; echo "=== 3. Tabela de rotas do projeto ===\n"; foreach ($rotas as $rota => $acao) { printf(" %-28s -> AlunoController::%s()\n", $rota, $acao); } echo "\n=== 4. Como o front controller monta o caminho todo ===\n"; $passos = [ '1. navegador pede /alunos/42/editar', '2. .htaccess reescreve tudo para public/index.php', '3. index.php le o metodo e o REQUEST_URI', '4. o roteador casa a URI com a tabela de rotas', '5. o controller chama o model e pede a view', '6. a view devolve HTML e o front controller imprime', ]; foreach ($passos as $p) { echo " {$p}\n"; } echo "\n=== 5. Estrutura de pastas do projeto ===\n"; $estrutura = [ 'public/' => 'index.php (front controller) e .htaccess', 'src/Controller/' => 'AlunoController.php', 'src/Model/' => 'Aluno.php', 'src/Database/' => 'Conexao.php', 'views/' => 'alunos/lista.php e alunos/form.php', 'config/' => 'config.php (le .env)', 'sql/' => 'schema.sql', ]; foreach ($estrutura as $pasta => $conteudo) { printf(" %-18s %s\n", $pasta, $conteudo); }
Saída real
=== 1. SQL do projeto (rode no MySQL antes do PHP) ===
CREATE DATABASE IF NOT EXISTS escola
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE escola;
CREATE TABLE IF NOT EXISTS alunos (
id INT AUTO_INCREMENT PRIMARY KEY,
nome VARCHAR(120) NOT NULL,
email VARCHAR(160) NOT NULL UNIQUE,
nota DECIMAL(4,2) NOT NULL DEFAULT 0.00,
criado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_alunos_nome (nome)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO alunos (nome, email, nota) VALUES
('Ana Souza', '[email protected]', 8.50),
('Bruno Lima', '[email protected]', 5.00),
('Carla Dias', '[email protected]', 9.00);
=== 2. View do formulario (novo e editar usam a mesma) ===
<form method="post" action="/alunos">
<input type="hidden" name="id" value="0">
<label>Nome <input type="text" name="nome" required></label>
<label>E-mail <input type="email" name="email" required></label>
<label>Nota <input type="number" name="nota" step="0.01" min="0" max="10"></label>
<button type="submit">Gravar</button>
</form>
=== 3. Tabela de rotas do projeto ===
GET /alunos -> AlunoController::index()
GET /alunos/novo -> AlunoController::novo()
POST /alunos -> AlunoController::salvar()
GET /alunos/{id} -> AlunoController::editar()
POST /alunos/{id} -> AlunoController::salvar()
GET /alunos/{id}/excluir -> AlunoController::excluir()
=== 4. Como o front controller monta o caminho todo ===
1. navegador pede /alunos/42/editar
2. .htaccess reescreve tudo para public/index.php
3. index.php le o metodo e o REQUEST_URI
4. o roteador casa a URI com a tabela de rotas
5. o controller chama o model e pede a view
6. a view devolve HTML e o front controller imprime
=== 5. Estrutura de pastas do projeto ===
public/ index.php (front controller) e .htaccess
src/Controller/ AlunoController.php
src/Model/ Aluno.php
src/Database/ Conexao.php
views/ alunos/lista.php e alunos/form.php
config/ config.php (le .env)
sql/ schema.sql