Dia 17 — MVC e projeto final: PHP e MySQL

Informatica · Conteudo · publicado em 02/10/2026
Dia 17 de 17

MVC e projeto final

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.

Aula 1

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

CamadaResponde porTrabalha comNunca deve
modeldado e regraPDO, arraygerar HTML
viewaparênciadado já prontoconsultar banco
controllero que fazermodel e viewmontar 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
Aula 2

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'
Aula 3

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>&lt;script&gt;alert(1)&lt;/script&gt;</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>
Aula 4

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

  1. Banco e tabela. id AUTO_INCREMENT e email UNIQUE: 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;
  1. Classe de conexão. Um método estático que monta o DSN e 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,
           ]);
       }
   }
  1. Model com o CRUD. Uma classe que recebe o PDO no 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();
       }
   }
  1. 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.

  1. 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; ?>
  1. 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.
  • $_POST nunca 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