PHP 8+ MySQL XAMPP PDO & MySQLi

CRUD com PHP e MySQL

Guia completo em português — desde a instalação do ambiente local com XAMPP até ao deploy no servidor de produção. Aprende a criar, ler, atualizar e eliminar registos.

🕐 ~3 horas de leitura
🎓 Iniciante ao Intermédio
💻 Windows / Mac / Linux
🆓 100% gratuito

O que vamos construir?

CRUD é o acrónimo das quatro operações fundamentais de qualquer aplicação com base de dados: Create, Read, Update e Delete. Vamos construir um sistema de gestão de contactos completo, com formulários HTML, lógica PHP e base de dados MySQL.

C — CREATE → Inserir novo contacto R — READ → Listar todos os contactos U — UPDATE → Editar um contacto D — DELETE → Eliminar um contacto
👤 Utilizador
HTML
Formulário
PHP
Lógica
MySQL
Base de Dados
HTML
Resultado

Ferramentas Necessárias

Para desenvolver localmente precisamos de um servidor web com suporte a PHP e MySQL. O XAMPP instala tudo de uma vez. Para escrever código, recomendamos o VS Code.

📦

XAMPP

Pacote que inclui Apache (servidor web), PHP e MySQL/MariaDB. Gratuito e multi-plataforma.

📝

Visual Studio Code

Editor de código gratuito da Microsoft. Com extensões para PHP, HTML e acesso FTP.

🗄

phpMyAdmin

Interface web incluída no XAMPP para gerir a base de dados MySQL visualmente.

🌐

Browser

Chrome ou Firefox — com as DevTools para depurar erros. Abre localhost para ver o projeto.

💡

Alternativas ao XAMPP — WAMP (Windows), MAMP (Mac/Windows) ou Laragon funcionam de forma semelhante. Este guia usa XAMPP por ser o mais universal.

Instalar e Configurar o XAMPP

O XAMPP é a forma mais rápida de ter um servidor PHP + MySQL a correr no teu computador.

  1. 1

    Descarregar o XAMPP

    Vai a apachefriends.org e descarrega a versão para o teu sistema operativo (Windows, Linux ou macOS). Escolhe a versão com PHP 8.x.

  2. 2

    Instalar — Windows

    Executa o instalador (.exe). Mantém as opções por defeito. Instala em C:\xampp (recomendado — evita pastas com espaços). Aceita os avisos do antivírus/firewall.

  3. 3

    Iniciar os Serviços

    Abre o XAMPP Control Panel. Clica em Start em:

    • Apache — o servidor web (HTTP)
    • MySQL — a base de dados

    Ambos ficam a verde quando estão a correr.

  4. 4

    Testar o Apache

    Abre o browser e vai a http://localhost. Deverás ver a página de boas-vindas do XAMPP.

  5. 5

    Abrir o phpMyAdmin

    Vai a http://localhost/phpmyadmin. Esta é a ferramenta para gerir bases de dados MySQL visualmente.

  6. 6

    Pasta do projeto — htdocs

    Todos os teus ficheiros PHP devem ficar em C:\xampp\htdocs\. Cria uma pasta para o projeto: C:\xampp\htdocs\contactos\. Acede em http://localhost/contactos/.

⚠️

Porto 80 ocupado? Se o Apache não inicia, o porto 80 pode estar a ser usado pelo Skype ou IIS. No XAMPP Control Panel → Apache → Config → httpd.conf — muda Listen 80 para Listen 8080. Depois acede em http://localhost:8080.

🐧

Linux (Ubuntu/Debian): Podes instalar via terminal:
sudo apt install apache2 php php-mysql mysql-server
A pasta do projeto fica em /var/www/html/.

Configurar o VS Code

O Visual Studio Code é gratuito e tem excelente suporte para PHP com extensões.

  1. 1

    Instalar o VS Code

    Descarrega em code.visualstudio.com e instala normalmente.

  2. 2

    Extensões recomendadas

    Abre o VS Code → clica no ícone de extensões (Ctrl+Shift+X) → pesquisa e instala:

    • PHP Intelephense — autocompletar e validação de PHP
    • PHP Debug — depuração com XDebug
    • HTML CSS Support — autocompletar em HTML/CSS
    • SFTP (Natizyskunk) — upload de ficheiros para o servidor
    • GitLens — controlo de versões Git
  3. 3

    Abrir a pasta do projeto

    File → Open Folder → navega até C:\xampp\htdocs\contactos. O VS Code mostra todos os ficheiros na barra lateral.

Criar a Base de Dados no MySQL

Vamos criar a base de dados e a tabela de contactos usando o phpMyAdmin.

  1. 1

    Abrir phpMyAdmin

    Vai a http://localhost/phpmyadmin. Utilizador: root. Palavra-passe: vazia (por defeito no XAMPP).

  2. 2

    Criar nova base de dados

    Clica em "Nova" na barra lateral esquerda. Nome: crud_contactos. Collation: utf8mb4_unicode_ci. Clica "Criar".

Criar a Tabela de Contactos

Com a base de dados selecionada, clica em "SQL" e executa o seguinte código:

criar_tabela.sql
SQL
-- Criar a base de dados (caso não exista)
CREATE DATABASE IF NOT EXISTS crud_contactos
  CHARACTER SET utf8mb4
  COLLATE utf8mb4_unicode_ci;

USE crud_contactos;

-- Criar a tabela de contactos
CREATE TABLE IF NOT EXISTS contactos (
  id         INT AUTO_INCREMENT PRIMARY KEY,
  nome       VARCHAR(100) NOT NULL,
  email      VARCHAR(150) NOT NULL UNIQUE,
  telefone   VARCHAR(20),
  cidade     VARCHAR(80),
  criado_em  TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  atualizado_em TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- Inserir dados de exemplo
INSERT INTO contactos (nome, email, telefone, cidade) VALUES
  ('Ana Silva', 'ana@exemplo.pt', '912345678', 'Lisboa'),
  ('Bruno Costa', 'bruno@exemplo.pt', '963214567', 'Porto'),
  ('Carla Mendes', 'carla@exemplo.pt', '917654321', 'Faro');
ColunaTipoDescrição
idINT AUTO_INCREMENT PKIdentificador único (gerado automaticamente)
nomeVARCHAR(100) NOT NULLNome do contacto — obrigatório
emailVARCHAR(150) UNIQUEEmail — obrigatório e único
telefoneVARCHAR(20)Número de telefone — opcional
cidadeVARCHAR(80)Cidade — opcional
criado_emTIMESTAMPData de criação (automática)
atualizado_emTIMESTAMPData da última atualização (automática)

Estrutura do Projeto

Uma boa organização de ficheiros facilita a manutenção. Cria esta estrutura em C:\xampp\htdocs\contactos\:

contactos/ ← pasta raiz em htdocs
  ├── config/
  │   └── database.php ← ligação à BD
  ├── includes/
  │   ├── header.php ← cabeçalho HTML
  │   └── footer.php ← rodapé HTML
  ├── css/
  │   └── estilo.css ← estilos
  ├── index.php ← listar contactos (READ)
  ├── criar.php ← formulário + inserir (CREATE)
  ├── editar.php ← formulário + atualizar (UPDATE)
  └── apagar.php ← eliminar (DELETE)

Boas práticas: Separar a configuração da base de dados num ficheiro próprio (database.php) evita repetição de código e facilita alterações futuras — em produção, basta mudar as credenciais neste único ficheiro.

Ligar o PHP à Base de Dados — PDO

Vamos usar PDO (PHP Data Objects) — a forma moderna e segura de comunicar com o MySQL. O PDO usa prepared statements que protegem contra injeção SQL.

ℹ️

PDO vs MySQLi: Ambos funcionam. PDO é preferível porque é agnóstico ao motor de base de dados (funciona com MySQL, PostgreSQL, SQLite...) e a sintaxe é mais limpa.

config/database.php
PHP
<?php
// Configurações da base de dados
$host     = 'localhost';    // servidor MySQL
$dbname   = 'crud_contactos'; // nome da BD
$username = 'root';           // utilizador (XAMPP: root)
$password = '';               // palavra-passe (XAMPP: vazia)
$charset  = 'utf8mb4';

// DSN (Data Source Name)
$dsn = "mysql:host=$host;dbname=$dbname;charset=$charset";

// Opções do PDO
$opcoes = [
  PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
  PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
  PDO::ATTR_EMULATE_PREPARES   => false,
];

try {
  $pdo = new PDO($dsn, $username, $password, $opcoes);
} catch (PDOException $e) {
  // Em produção: não mostrar detalhes do erro ao utilizador!
  error_log($e->getMessage());
  die('Erro ao ligar à base de dados. Tente mais tarde.');
}
?>
⚠️

Em produção: Nunca uses root sem palavra-passe. Cria um utilizador MySQL com permissões limitadas à tua base de dados. Mais informação na secção de Segurança.

Header e Footer Reutilizáveis

Evita repetir o HTML base em cada ficheiro. Cria partials que são incluídos em todas as páginas.

includes/header.php
PHP
<?php
if (session_status() === PHP_SESSION_NONE) {
  session_start(); // para mensagens flash
}
?>
<!DOCTYPE html>
<html lang="pt-PT">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Gestão de Contactos</title>
  <link rel="stylesheet" href="/contactos/css/estilo.css">
</head>
<body>
<header>
  <nav>
    <h1>📒 Contactos</h1>
    <a href="index.php">Listar</a>
    <a href="criar.php">+ Novo Contacto</a>
  </nav>
</header>
<main>
<?php
// Mostrar mensagens flash (sucesso/erro)
if (isset($_SESSION['msg'])): ?>
  <div class="flash <?= htmlspecialchars($_SESSION['tipo']) ?>">
    <?= htmlspecialchars($_SESSION['msg']) ?>
  </div>
<?php
  unset($_SESSION['msg'], $_SESSION['tipo']);
endif; ?>
includes/footer.php
PHP
</main>
<footer>
  <p>CRUD PHP + MySQL © <?= date('Y') ?></p>
</footer>
</body>
</html>

➕ Criar — Inserir Novo Contacto

O ficheiro criar.php faz duas coisas: mostra o formulário (GET) e processa os dados submetidos (POST).

criar.php
PHP
<?php
require_once 'config/database.php';
require_once 'includes/header.php';

$erros = [];
$dados = ['nome' => '', 'email' => '', 'telefone' => '', 'cidade' => ''];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {

  // 1. Recolher e limpar dados do formulário
  $dados['nome']     = trim($_POST['nome'] ?? '');
  $dados['email']    = trim(strtolower($_POST['email'] ?? ''));
  $dados['telefone'] = trim($_POST['telefone'] ?? '');
  $dados['cidade']   = trim($_POST['cidade'] ?? '');

  // 2. Validação
  if (empty($dados['nome'])) {
    $erros[] = 'O nome é obrigatório.';
  }
  if (!filter_var($dados['email'], FILTER_VALIDATE_EMAIL)) {
    $erros[] = 'Email inválido.';
  }

  // 3. Se não há erros, inserir na BD
  if (empty($erros)) {
    try {
      $stmt = $pdo->prepare(
        "INSERT INTO contactos (nome, email, telefone, cidade)
         VALUES (:nome, :email, :telefone, :cidade)"
      );
      $stmt->execute([
        ':nome'     => $dados['nome'],
        ':email'    => $dados['email'],
        ':telefone' => $dados['telefone'],
        ':cidade'   => $dados['cidade'],
      ]);

      // Guardar mensagem na sessão e redirecionar
      $_SESSION['msg']  = 'Contacto criado com sucesso!';
      $_SESSION['tipo'] = 'sucesso';
      header('Location: index.php');
      exit;

    } catch (PDOException $e) {
      if ($e->getCode() == '23000') {
        $erros[] = 'Este email já está registado.';
      } else {
        $erros[] = 'Erro ao guardar. Tente novamente.';
      }
    }
  }
}
?>

<div class="container">
  <h2>Novo Contacto</h2>

  <?php if (!empty($erros)): ?>
    <div class="flash erro">
      <ul>
        <?php foreach ($erros as $erro): ?>
          <li><?= htmlspecialchars($erro) ?></li>
        <?php endforeach; ?>
      </ul>
    </div>
  <?php endif; ?>

  <form method="post" action="criar.php">
    <div class="campo">
      <label>Nome *</label>
      <input type="text" name="nome"
             value="<?= htmlspecialchars($dados['nome']) ?>"
             required>
    </div>
    <div class="campo">
      <label>Email *</label>
      <input type="email" name="email"
             value="<?= htmlspecialchars($dados['email']) ?>"
             required>
    </div>
    <div class="campo">
      <label>Telefone</label>
      <input type="tel" name="telefone"
             value="<?= htmlspecialchars($dados['telefone']) ?>">
    </div>
    <div class="campo">
      <label>Cidade</label>
      <input type="text" name="cidade"
             value="<?= htmlspecialchars($dados['cidade']) ?>">
    </div>
    <button type="submit">Guardar Contacto</button>
    <a href="index.php">Cancelar</a>
  </form>
</div>

<?php require_once 'includes/footer.php'; ?>
🔑

Prepared Statements: O método prepare() + execute() evita SQL Injection. Os valores :nome, :email etc. são parâmetros — o PDO nunca os interpreta como SQL, independentemente do que o utilizador escreva.

📄 Listar — Mostrar Todos os Contactos

O ficheiro index.php é a página principal — lista todos os contactos da base de dados.

index.php
PHP
<?php
require_once 'config/database.php';
require_once 'includes/header.php';

// Pesquisa (opcional)
$pesquisa = trim($_GET['q'] ?? '');

if ($pesquisa !== '') {
  $stmt = $pdo->prepare(
    "SELECT * FROM contactos
     WHERE nome LIKE :q OR email LIKE :q OR cidade LIKE :q
     ORDER BY nome ASC"
  );
  $stmt->execute([':q' => "%$pesquisa%"]);
} else {
  $stmt = $pdo->query("SELECT * FROM contactos ORDER BY nome ASC");
}

$contactos = $stmt->fetchAll();
$total     = count($contactos);
?>

<div class="container">
  <div class="topo">
    <h2>Contactos <span class="badge"><?= $total ?></span></h2>
    <a href="criar.php" class="btn-novo">+ Novo</a>
  </div>

  <!-- Barra de pesquisa -->
  <form method="get">
    <input type="search" name="q"
           placeholder="Pesquisar por nome, email ou cidade..."
           value="<?= htmlspecialchars($pesquisa) ?>">
    <button>Pesquisar</button>
  </form>

  <?php if (empty($contactos)): ?>
    <p class="vazio">Nenhum contacto encontrado.</p>
  <?php else: ?>
    <table>
      <thead>
        <tr>
          <th>Nome</th>
          <th>Email</th>
          <th>Telefone</th>
          <th>Cidade</th>
          <th>Ações</th>
        </tr>
      </thead>
      <tbody>
        <?php foreach ($contactos as $c): ?>
          <tr>
            <td><?= htmlspecialchars($c['nome']) ?></td>
            <td><?= htmlspecialchars($c['email']) ?></td>
            <td><?= htmlspecialchars($c['telefone']) ?></td>
            <td><?= htmlspecialchars($c['cidade']) ?></td>
            <td class="acoes">
              <a href="editar.php?id=<?= $c['id'] ?>">✏️ Editar</a>
              <a href="apagar.php?id=<?= $c['id'] ?>"
                 onclick="return confirm('Eliminar este contacto?')"
                 class="apagar">🗑 Apagar</a>
            </td>
          </tr>
        <?php endforeach; ?>
      </tbody>
    </table>
  <?php endif; ?>
</div>

<?php require_once 'includes/footer.php'; ?>

✏️ Editar — Atualizar um Contacto

O ficheiro editar.php recebe o id por GET, carrega o contacto, mostra o formulário preenchido e guarda as alterações por POST.

editar.php
PHP
<?php
require_once 'config/database.php';
require_once 'includes/header.php';

// Validar e obter o ID
$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
if (!$id) {
  header('Location: index.php');
  exit;
}

// Buscar o contacto existente
$stmt = $pdo->prepare("SELECT * FROM contactos WHERE id = :id");
$stmt->execute([':id' => $id]);
$contacto = $stmt->fetch();

if (!$contacto) {
  $_SESSION['msg']  = 'Contacto não encontrado.';
  $_SESSION['tipo'] = 'erro';
  header('Location: index.php');
  exit;
}

$erros = [];
$dados = $contacto; // pré-preencher com dados existentes

if ($_SERVER['REQUEST_METHOD'] === 'POST') {

  $dados['nome']     = trim($_POST['nome'] ?? '');
  $dados['email']    = trim(strtolower($_POST['email'] ?? ''));
  $dados['telefone'] = trim($_POST['telefone'] ?? '');
  $dados['cidade']   = trim($_POST['cidade'] ?? '');

  if (empty($dados['nome']))  $erros[] = 'O nome é obrigatório.';
  if (!filter_var($dados['email'], FILTER_VALIDATE_EMAIL))
    $erros[] = 'Email inválido.';

  if (empty($erros)) {
    try {
      $stmt = $pdo->prepare(
        "UPDATE contactos
         SET nome=:nome, email=:email, telefone=:telefone, cidade=:cidade
         WHERE id=:id"
      );
      $stmt->execute([
        ':nome'     => $dados['nome'],
        ':email'    => $dados['email'],
        ':telefone' => $dados['telefone'],
        ':cidade'   => $dados['cidade'],
        ':id'       => $id,
      ]);
      $_SESSION['msg']  = 'Contacto atualizado com sucesso!';
      $_SESSION['tipo'] = 'sucesso';
      header('Location: index.php');
      exit;
    } catch (PDOException $e) {
      $erros[] = 'Erro ao atualizar. Tente novamente.';
    }
  }
}
?>

<div class="container">
  <h2>Editar Contacto #<?= $id ?></h2>

  <?php if (!empty($erros)): ?>
    <div class="flash erro">
      <?php foreach ($erros as $e): ?>
        <p><?= htmlspecialchars($e) ?></p>
      <?php endforeach; ?>
    </div>
  <?php endif; ?>

  <form method="post">
    <div class="campo">
      <label>Nome *</label>
      <input type="text" name="nome"
             value="<?= htmlspecialchars($dados['nome']) ?>" required>
    </div>
    <div class="campo">
      <label>Email *</label>
      <input type="email" name="email"
             value="<?= htmlspecialchars($dados['email']) ?>" required>
    </div>
    <div class="campo">
      <label>Telefone</label>
      <input type="tel" name="telefone"
             value="<?= htmlspecialchars($dados['telefone']) ?>">
    </div>
    <div class="campo">
      <label>Cidade</label>
      <input type="text" name="cidade"
             value="<?= htmlspecialchars($dados['cidade']) ?>">
    </div>
    <button type="submit">Guardar Alterações</button>
    <a href="index.php">Cancelar</a>
  </form>
</div>

<?php require_once 'includes/footer.php'; ?>

🗑 Apagar — Eliminar um Contacto

O ficheiro apagar.php é simples: valida o ID, elimina o registo e redireciona. Não tem formulário — a confirmação é feita com JavaScript no lado do cliente.

apagar.php
PHP
<?php
require_once 'config/database.php';

session_start();

// 1. Validar o ID recebido por GET
$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);

if (!$id) {
  header('Location: index.php');
  exit;
}

try {
  // 2. Verificar se o contacto existe antes de apagar
  $check = $pdo->prepare("SELECT id FROM contactos WHERE id = :id");
  $check->execute([':id' => $id]);

  if ($check->rowCount() === 0) {
    $_SESSION['msg']  = 'Contacto não encontrado.';
    $_SESSION['tipo'] = 'erro';
    header('Location: index.php');
    exit;
  }

  // 3. Eliminar o registo
  $stmt = $pdo->prepare("DELETE FROM contactos WHERE id = :id");
  $stmt->execute([':id' => $id]);

  $_SESSION['msg']  = 'Contacto eliminado com sucesso.';
  $_SESSION['tipo'] = 'sucesso';

} catch (PDOException $e) {
  $_SESSION['msg']  = 'Erro ao eliminar o contacto.';
  $_SESSION['tipo'] = 'erro';
}

header('Location: index.php');
exit;
🛡️

Nunca elimines por GET sem confirmação! Robots e crawlers podem seguir links de apagar. A solução mais segura é usar um formulário POST com um token CSRF. O confirm() de JavaScript é uma solução rápida mas não é suficiente em produção.

CSS — Estilo Básico

Um CSS funcional e limpo para o projeto:

css/estilo.css
CSS
/* Reset e base */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
  font-family: 'Segoe UI', sans-serif;
  background: #f4f6f9;
  color: #333;
  font-size: 15px;
}

/* Navegação */
header { background: #2c3e50; color: white; }
header nav {
  max-width: 1100px; margin: 0 auto;
  padding: 14px 20px;
  display: flex; align-items: center; gap: 20px;
}
header nav h1 { font-size: 1.2rem; margin-right: auto; }
header nav a {
  color: rgba(255,255,255,.8);
  text-decoration: none; font-size: 14px;
  padding: 6px 14px; border-radius: 6px;
  transition: background .2s;
}
header nav a:hover { background: rgba(255,255,255,.15); color: white; }

/* Conteúdo principal */
main { max-width: 1100px; margin: 30px auto; padding: 0 20px; }
footer { text-align: center; padding: 24px; color: #999; font-size: 13px; }

/* Mensagens flash */
.flash {
  padding: 12px 18px; border-radius: 8px;
  margin-bottom: 20px; font-size: 14px;
}
.flash.sucesso { background: #d1fae5; border: 1px solid #6ee7b7; color: #065f46; }
.flash.erro    { background: #fee2e2; border: 1px solid #fca5a5; color: #991b1b; }

/* Container */
.container {
  background: white; border-radius: 12px;
  padding: 28px 32px;
  box-shadow: 0 2px 12px rgba(0,0,0,.07);
}
.topo {
  display: flex; justify-content: space-between;
  align-items: center; margin-bottom: 20px;
}
.topo h2 { font-size: 1.4rem; }
.badge {
  background: #e0e7ff; color: #4338ca;
  font-size: 12px; padding: 2px 8px; border-radius: 10px;
  margin-left: 8px;
}

/* Tabela */
table { width: 100%; border-collapse: collapse; margin-top: 16px; }
thead th {
  background: #f8fafc; padding: 10px 14px;
  text-align: left; font-size: 12px;
  text-transform: uppercase; color: #64748b;
  border-bottom: 2px solid #e2e8f0;
}
tbody td { padding: 12px 14px; border-bottom: 1px solid #f1f5f9; }
tbody tr:hover td { background: #fafbfc; }
.acoes { display: flex; gap: 8px; }
.acoes a {
  font-size: 13px; text-decoration: none;
  padding: 4px 10px; border-radius: 5px;
  transition: background .15s;
}
.acoes a:first-child { background: #eff6ff; color: #1d4ed8; }
.acoes a:first-child:hover { background: #dbeafe; }
.acoes a.apagar { background: #fef2f2; color: #dc2626; }
.acoes a.apagar:hover { background: #fee2e2; }

/* Formulários */
.campo { margin-bottom: 18px; }
.campo label {
  display: block; margin-bottom: 6px;
  font-size: 13px; font-weight: 600; color: #374151;
}
.campo input {
  width: 100%; padding: 10px 14px;
  border: 1px solid #d1d5db; border-radius: 8px;
  font-size: 15px; transition: border-color .2s;
}
.campo input:focus {
  outline: none; border-color: #6366f1;
  box-shadow: 0 0 0 3px rgba(99,102,241,.15);
}
button[type="submit"] {
  background: #4f46e5; color: white;
  border: none; padding: 10px 24px;
  border-radius: 8px; font-size: 15px;
  font-weight: 600; cursor: pointer;
  transition: background .2s;
}
button[type="submit"]:hover { background: #4338ca; }
.btn-novo {
  background: #4f46e5; color: white;
  text-decoration: none; padding: 8px 18px;
  border-radius: 8px; font-size: 14px; font-weight: 600;
}

/* Pesquisa */
form[method="get"] {
  display: flex; gap: 8px; margin-bottom: 20px;
}
form[method="get"] input {
  flex: 1; padding: 9px 14px;
  border: 1px solid #d1d5db; border-radius: 8px;
  font-size: 14px;
}
form[method="get"] button {
  padding: 9px 18px; background: #64748b; border: none;
  color: white; border-radius: 8px; cursor: pointer;
}
.vazio { text-align: center; color: #94a3b8; padding: 40px; }

/* Responsivo */
@media (max-width: 640px) {
  .container { padding: 16px; }
  table { font-size: 13px; }
  thead th:nth-child(3),
  thead th:nth-child(4),
  tbody td:nth-child(3),
  tbody td:nth-child(4) { display: none; }
}

🔒 Segurança Essencial

Antes de colocar qualquer aplicação online, verifica estas práticas obrigatórias:

💉

SQL Injection

✅ Sempre usar prepared statements com PDO. Nunca concatenar variáveis diretamente em queries SQL.

🔐

XSS (Cross-Site Scripting)

✅ Sempre usar htmlspecialchars() ao mostrar dados do utilizador em HTML. Nunca fazer echo $variavel diretamente.

🎭

CSRF

Adiciona tokens CSRF nos formulários para evitar ataques de submissão forjada. Verifica o token no servidor.

🔑

Palavras-passe

Se o projeto tiver autenticação: usa sempre password_hash() e password_verify(). Nunca guarda em texto simples!

Exemplo: Token CSRF
PHP
// Gerar token no formulário
if (empty($_SESSION['csrf_token'])) {
  $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// No HTML do formulário:
// <input type="hidden" name="csrf" value="<?= $_SESSION['csrf_token'] ?>">

// Verificar no POST
if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf'] ?? '')) {
  die('Pedido inválido.');
}

// Exemplo de hash de palavra-passe
$hash = password_hash($_POST['password'], PASSWORD_BCRYPT);
// Verificar ao fazer login:
if (password_verify($_POST['password'], $hash_da_bd)) {
  // palavra-passe correta
}
🚨

Em produção, no ficheiro database.php: Muda o utilizador MySQL de root para um utilizador com permissões mínimas (apenas SELECT, INSERT, UPDATE, DELETE na tua base de dados). Nunca expõe o root online.

Criar utilizador MySQL limitado
SQL
-- Correr no phpMyAdmin ou terminal MySQL do servidor
CREATE USER 'app_user'@'localhost' IDENTIFIED BY 'password_forte_aqui';
GRANT SELECT, INSERT, UPDATE, DELETE ON crud_contactos.* TO 'app_user'@'localhost';
FLUSH PRIVILEGES;

🌐 Escolher um Serviço de Alojamento

Para colocar o projeto online precisas de um serviço de alojamento com suporte a PHP e MySQL. Aqui ficam as melhores opções para projetos de aprendizagem e produção:

GRÁTIS

InfinityFree

€0/mês
  • PHP 7/8
  • MySQL incluído
  • phpMyAdmin
  • Subdomínio grátis
  • Sem publicidade
GRÁTIS

000webhost

€0/mês
  • PHP + MySQL
  • 1 GB espaço
  • Painel Hepsia
  • Fácil para iniciantes
RECOMENDADO

Hostinger

~€2/mês
  • PHP 8.x
  • MySQL 8
  • SSL grátis
  • Domínio incluído
  • Painel hPanel

SiteGround

~€4/mês
  • PHP 8.x + MySQL
  • Backups diários
  • SSL grátis
  • Suporte 24/7

Alojamento PT

~€3/mês
  • Servidores em Portugal
  • PHP + MySQL
  • Suporte em português
  • cPanel incluído
🎓

Para aprendizagem: Começa com o InfinityFree ou 000webhost (gratuitos). Para projetos reais, o Hostinger tem excelente relação qualidade/preço e suporte em português.

Criar conta no Hostinger (exemplo)

  1. 1

    Registar conta

    Vai a hostinger.pt, escolhe o plano, regista e faz o pagamento. Recebes acesso ao painel hPanel.

  2. 2

    Configurar o domínio

    Se incluído, configura o teu domínio. Caso contrário, usa o subdomínio gratuito (ex: meusite.hostingersite.com).

  3. 3

    Criar base de dados MySQL

    hPanel → Bases de DadosMySQLCriar nova BD. Anota o nome, utilizador e palavra-passe.

  4. 4

    Importar a estrutura SQL

    hPanel → phpMyAdmin → seleciona a BD → aba Importar → carrega o ficheiro .sql exportado do XAMPP (phpMyAdmin local → Exportar).

  5. 5

    Anotar credenciais

    Guarda o host (geralmente localhost ou um endereço específico), nome da BD, utilizador e palavra-passe — vais precisar deles no database.php.

🚀 Upload dos Ficheiros — Deploy

Tens três opções para enviar os ficheiros para o servidor:

🗂

Gestor de Ficheiros Web

O painel de alojamento (cPanel/hPanel) tem um gestor de ficheiros web. Simples mas lento para muitos ficheiros.

📡

FTP / SFTP

Usa o FileZilla (gratuito) ou a extensão SFTP do VS Code. Mais rápido e permite sincronização.

🔧

Git + CI/CD

Para projetos maiores: usa Git com GitHub Actions ou similar para deploy automático. Mais avançado.

📡 Upload com FileZilla (recomendado)

  1. 1

    Instalar o FileZilla

    Descarrega o FileZilla Client em filezilla-project.org e instala.

  2. 2

    Obter as credenciais FTP

    No painel do teu alojamento: procura FTP / Gestor de FTP. Cria uma conta FTP ou usa as credenciais fornecidas. Anota: Host, Utilizador, Palavra-passe e Porto (normalmente 21 para FTP ou 22 para SFTP).

  3. 3

    Ligar ao servidor

    FileZilla → Abrir Gestor de SitesNovo Site. Preenche os dados FTP e clica Ligar. Aparece o sistema de ficheiros do servidor do lado direito.

  4. 4

    Navegar para a pasta pública

    No servidor (lado direito do FileZilla), navega até public_html/ — esta é a pasta raiz do teu site.

  5. 5

    Enviar os ficheiros

    No lado esquerdo (local), navega até C:\xampp\htdocs\contactos\. Seleciona todos os ficheiros e arrasta para a pasta public_html/ no servidor (ou para uma subpasta, ex: public_html/contactos/).

  6. 6

    Atualizar o database.php

    Muito importante: atualiza as credenciais no ficheiro config/database.php com as credenciais da base de dados do servidor de produção antes de fazer upload.

config/database.php — versão produção
PHP
<?php
// ⚠️  Credenciais do servidor de PRODUÇÃO
// (diferentes das credenciais do XAMPP local!)
$host     = 'localhost';          // geralmente localhost
$dbname   = 'u123456789_crud';    // nome da BD no servidor
$username = 'u123456789_app';     // utilizador MySQL criado
$password = 'Pd9#xK2!mR';         // palavra-passe forte!
$charset  = 'utf8mb4';

$dsn = "mysql:host=$host;dbname=$dbname;charset=$charset";
$opcoes = [
  PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
  PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
  PDO::ATTR_EMULATE_PREPARES   => false,
];

try {
  $pdo = new PDO($dsn, $username, $password, $opcoes);
} catch (PDOException $e) {
  error_log($e->getMessage()); // guarda no log do servidor
  die('Erro de ligação. Contacte o administrador.');
}
?>

🔧 Upload com SFTP do VS Code

  1. 1

    Instalar extensão SFTP

    VS Code → Extensões → pesquisa "SFTP" (autor: Natizyskunk) → instala.

  2. 2

    Configurar o SFTP

    Ctrl+Shift+P → SFTP: Config. Edita o ficheiro criado:

.vscode/sftp.json
JSON
{
  "name": "Servidor Produção",
  "host": "ftp.meudominio.pt",
  "protocol": "sftp",
  "port": 22,
  "username": "utilizador_ftp",
  "password": "palavrapasse_ftp",
  "remotePath": "/public_html/contactos",
  "uploadOnSave": false,
  "ignore": [".vscode", ".git", "*.log"]
}
  1. 3

    Fazer upload

    Ctrl+Shift+P → SFTP: Upload Folder para enviar toda a pasta de uma vez. Ou clica com botão direito num ficheiro → Upload para enviar individualmente.

🎉

Projeto online! Após o upload, abre o browser em https://meudominio.pt/contactos/ e o teu CRUD PHP está a funcionar em produção. Verifica se o SSL (HTTPS) está ativo para proteger os dados dos utilizadores.

📤 Exportar a BD do XAMPP para o servidor

  1. 1

    Exportar do XAMPP local

    phpMyAdmin local (localhost/phpmyadmin) → seleciona crud_contactos → aba Exportar → método Rápido → formato SQLExecutar. Guarda o ficheiro .sql.

  2. 2

    Importar no servidor

    phpMyAdmin do servidor → seleciona a base de dados do servidor → aba Importar → escolhe o ficheiro .sqlExecutar. A estrutura e os dados são importados!

✅ Checklist Final de Deploy

ItemDescriçãoEstado
database.php atualizadoCredenciais de produção no lugar das do XAMPP
BD importada no servidorTabela criada e dados de exemplo importados
Ficheiros enviados por FTPTodos os .php, .css em public_html/
HTTPS ativoSSL configurado no painel do alojamento
Permissões de ficheirosPHP: 644, Pastas: 755
Erros ocultos ao utilizadordisplay_errors = Off em produção
Utilizador MySQL limitadoSem usar root em produção
Testar todas as operaçõesCriar, listar, editar e apagar no servidor
Backup automático ativoConfigurado no painel do alojamento
Desativar exibição de erros em produção
PHP
// No início do index.php ou num ficheiro de bootstrap:
if ($_SERVER['SERVER_NAME'] !== 'localhost') {
  ini_set('display_errors', 0);
  ini_set('log_errors', 1);
  error_reporting(E_ALL);
}