DevTools

Cheatsheet CodeIgniter

Framework PHP leve e simples

Voltar às linguagens
CodeIgniter
95 cards encontrados
Categorias:
Versões:

Instalação e Estrutura


10 cards
Instalação
// Criar projeto com Composer:
composer create-project codeigniter4/appstarter meu-projeto
cd meu-projeto

// Iniciar servidor de desenvolvimento:
php spark serve

// Requisitos:
// - PHP 8.1+
// - ext-intl, ext-mbstring
// - Composer

// Alternativa (download manual):
// codeigniter.com/download

composer create-project cria o projeto com todas as dependências. php spark serve inicia o servidor de desenvolvimento na porta 8080. O CodeIgniter 4 requer PHP 8.1+ com extensões intl e mbstring. É o framework PHP mais leve — sem dependências obrigatórias além do core.

Autoloading e Namespaces
// app/Config/Autoload.php
public array $psr4 = [
    APP_NAMESPACE => APPPATH,
    'App' => APPPATH,
];

// Adicionar namespace custom:
public array $psr4 = [
    'App' => APPPATH,
    'Acme' => ROOTPATH . 'acme/src',
];

// Uso em controllers/models:
use App\Models\ProdutoModel;
use Acme\Pagamentos\Gateway;

// Classmap (ficheiros específicos):
public array $classmap = [
    'MinhaClasse' => APPPATH . 'Libraries/MinhaClasse.php',
];

O CodeIgniter 4 usa autoloading PSR-4 — cada namespace mapeia para um diretório. APP_NAMESPACE aponta para app/. Namespaces custom permitem organizar bibliotecas externas. classmap mapeia classes individuais. O autoloader é rápido e não requer composer dump-autoload para classes em app/.

BaseController
<?php
namespace App\Controllers;

use CodeIgniter\Controller;

class BaseController extends Controller
{
    protected $helpers = ['url', 'form'];
    protected $session;

    public function initController(
        $request, $response, $logger
    ) {
        parent::initController(
            $request, $response, $logger
        );
        $this->session = session();
    }
}

// Todos os controllers estendem BaseController
class Produto extends BaseController
{
    // $this->session já disponível
}

BaseController é o pai de todos os controllers — ideal para carregar helpers, iniciar sessão e definir dados partilhados. initController() é o "construtor" do CI4 (não usar __construct). $helpers carrega helpers automaticamente em todos os controllers. Propriedades definidas aqui ficam disponíveis em toda a aplicação.

Estrutura de pastas
app/
  Config/          // configurações
  Controllers/     // controladores
  Models/          // modelos
  Views/           // templates
  Database/
    Migrations/    // migrações
    Seeds/         // seeders
  Filters/         // middleware
  Libraries/       // bibliotecas custom
public/
  index.php        // front controller
  assets/          // CSS, JS, imagens
writable/
  logs/            // logs da aplicação
  cache/           // cache
  uploads/         // ficheiros enviados

A pasta app/ contém todo o código da aplicação (MVC). public/ é o único diretório público (front controller index.php). writable/ armazena logs, cache e uploads (fora do acesso web). Esta separação protege o código-fonte — apenas public/ é servido pelo web server.

Constantes e paths
// Constantes de caminho (app/Config/Paths.php):
APPPATH      // app/
ROOTPATH     // raiz do projeto
WRITEPATH    // writable/
PUBLICPATH   // public/
SYSTEMPATH   // vendor/codeigniter4/framework/system/

// Uso:
$logFile = WRITEPATH . 'logs/app.log';
$uploadDir = PUBLICPATH . 'uploads/';
$configFile = APPPATH . 'Config/App.php';

// Constantes de ambiente:
ENVIRONMENT  // 'development' ou 'production'
CI_DEBUG     // true em development

// Verificar ambiente:
if (ENVIRONMENT === 'development') {
    // código só em dev
}

Constantes de caminho (APPPATH, WRITEPATH, PUBLICPATH) evitam caminhos relativos frágeis. ENVIRONMENT indica o modo atual (development/production). CI_DEBUG é true em development. Estas constantes são definidas em app/Config/Paths.php e disponíveis globalmente sem import.

Composer e dependências
// composer.json (dependências):
{
    "require": {
        "php": "^8.1",
        "codeigniter4/framework": "^4.4"
    },
    "require-dev": {
        "fakerphp/faker": "^1.23",
        "codeigniter4/devkit": "^1.2",
        "phpunit/phpunit": "^10.5"
    }
}

// Instalar dependências:
composer install

// Adicionar pacote:
composer require dompdf/dompdf

// Atualizar framework:
composer update codeigniter4/framework

// Scripts:
composer test    // executar testes
composer analyze // análise estática

O CodeIgniter 4 é instalado via Composercodeigniter4/framework é o core. codeigniter4/devkit adiciona ferramentas de desenvolvimento (debugbar, faker). composer require adiciona pacotes de terceiros. O framework é minimalista — a maioria das funcionalidades é nativa, sem pacotes extra.

Configuração (.env)
# Copiar template:
cp env .env

# .env (configurações de ambiente):
CI_ENVIRONMENT = development

app.baseURL = "http://localhost:8080/"
app.forceGlobalSecureRequests = false

database.default.hostname = localhost
database.default.database = minha_db
database.default.username = root
database.default.password =
database.default.DBDriver = MySQLi

# Produção:
# CI_ENVIRONMENT = production

O ficheiro .env sobrescreve configurações do app/Config/ — nunca commitar (está no .gitignore). CI_ENVIRONMENT controla debug bar e error reporting. Em produção, definir production desativa detalhes de erro. app.baseURL deve ser a URL raiz do projeto.

Config App.php
// app/Config/App.php
public string $baseURL = 'http://localhost:8080/';
public string $indexPage = 'index.php';
public string $uriProtocol = 'REQUEST_URI';
public string $defaultLocale = 'pt';
public bool $negotiateLocale = false;
public array $supportedLocales = ['pt', 'en'];
public string $defaultTimezone = 'Europe/Lisbon';

// Session:
public string $sessionDriver = 'CodeIgniter\Session\Handlers\FileHandler';
public string $sessionCookieName = 'ci_session';
public int $sessionExpiration = 7200;

// Security:
public bool $CSPEnabled = false;

app/Config/App.php contém configurações globais: baseURL, locale, timezone, sessão e segurança. defaultLocale define o idioma padrão. sessionExpiration em segundos (7200 = 2h). CSPEnabled ativa Content Security Policy. A maioria pode ser sobrescrita no .env.

Configuração de BD
// app/Config/Database.php
public array $default = [
    'DSN'      => '',
    'hostname' => 'localhost',
    'username' => 'root',
    'password' => '',
    'database' => 'minha_db',
    'DBDriver' => 'MySQLi',
    'DBPrefix' => '',
    'pConnect' => false,
    'DBDebug'  => true,
    'charset'  => 'utf8mb4',
    'DBCollat' => 'utf8mb4_general_ci',
];

// Drivers suportados:
// MySQLi, Postgre, SQLite3, SQLSRV, OCI8

A configuração de BD fica em app/Config/Database.php ou no .env. DBDriver define o driver (MySQLi, Postgre, SQLite3, SQLSRV). charset utf8mb4 suporta emoji e caracteres especiais. DBDebug mostra erros SQL em desenvolvimento. Suporta múltiplas conexões com grupos diferentes.

Múltiplos ambientes
# .env (development):
CI_ENVIRONMENT = development
database.default.hostname = localhost
database.default.database = minha_db_dev

# .env.production:
CI_ENVIRONMENT = production
database.default.hostname = db.servidor.com
database.default.database = minha_db_prod

# Comportamento por ambiente:
# development:
#   - Debug toolbar visível
#   - Erros detalhados
#   - CI_DEBUG = true

# production:
#   - Erros genéricos (sem stack trace)
#   - Debug toolbar oculta
#   - CI_DEBUG = false

CI_ENVIRONMENT controla o comportamento da aplicação. Em development, a debug toolbar mostra queries, tempo e memória. Em production, erros são genéricos (sem expor stack traces). Pode usar ficheiros .env diferentes por ambiente. O CodeIgniter não tem .env.example — usa o ficheiro env como template.

Rotas e Controladores


11 cards
Rotas básicas
// app/Config/Routes.php
use CodeIgniter\Router\RouteCollection;

$routes->get('/', 'Home::index');
$routes->get('produtos', 'Produto::listar');
$routes->post('produtos', 'Produto::criar');
$routes->get('produtos/(:num)', 'Produto::ver/$1');
$routes->put('produtos/(:num)', 'Produto::atualizar/$1');
$routes->delete('produtos/(:num)', 'Produto::eliminar/$1');

// Placeholders:
// (:num)    - apenas números
// (:alpha)  - apenas letras
// (:any)    - qualquer caractere
// (:segment) - segmento URI (sem /)
// (:alphanum) - alfanumérico

Rotas são definidas em app/Config/Routes.php com verbos HTTP (get, post, put, delete). Placeholders como (:num) capturam segmentos da URI e passam como argumentos ($1). O formato é 'Controller::metodo'. Rotas são avaliadas na ordem de definição.

Redirecionamento
// Redirecionar para URL:
return redirect()->to('/produtos');

// Para rota nomeada:
return redirect()->route('nome_rota');

// Voltar atrás:
return redirect()->back();

// Com flash messages:
return redirect()->back()
    ->with('sucesso', 'Guardado com sucesso!');

// Com input antigo (re-popular form):
return redirect()->back()
    ->withInput()
    ->with('erro', 'Dados inválidos');

// Código HTTP custom:
return redirect()->to('/login')->with('msg', 'Sessão expirada');

// Na view:
// <?= session()->getFlashdata('sucesso') ?>

redirect() retorna resposta de redirecionamento. ->with() define flash messages (disponíveis só no próximo request). ->withInput() preserva input do formulário (via old() na view). ->route() usa nomes de rotas (mais seguro que URLs hardcoded). Flash messages são ideais para feedback pós-ação (PRG pattern).

Request e resposta
// No controller:
public function index()
{
    // Request:
    $method = $this->request->getMethod();
    $uri = $this->request->getUri();
    $ip = $this->request->getIPAddress();
    $isAjax = $this->request->isAJAX();
    $isSecure = $this->request->isSecure();

    // Headers:
    $auth = $this->request->getHeaderLine('Authorization');

    // Resposta custom:
    return $this->response
        ->setStatusCode(201)
        ->setHeader('X-Custom', 'valor')
        ->setBody($conteudo);

    // Download:
    return $this->response
        ->download('ficheiro.pdf', $dados);
}

$this->request fornece acesso ao request HTTP: método, URI, IP, headers, AJAX. $this->response permite resposta custom com status code, headers e body. ->download() força download de ficheiro. isAJAX() deteta pedidos XMLHttpRequest. Estes objetos são injetados automaticamente no controller via initController().

Resource routes
// Rotas RESTful automáticas:
$routes->resource('produtos');
// Gera 7 rotas:
// GET    /produtos        -> index
// GET    /produtos/new    -> new
// POST   /produtos        -> create
// GET    /produtos/(:num) -> show
// GET    /produtos/(:num)/edit -> edit
// PUT    /produtos/(:num) -> update
// DELETE /produtos/(:num) -> delete

// Presenter (formulários HTML):
$routes->presenter('produtos');

// Limitar métodos:
$routes->resource('produtos', [
    'only' => ['index', 'show', 'create']
]);

// Controller diferente:
$routes->resource('produtos', [
    'controller' => 'Api\ProdutoController'
]);

$routes->resource() gera as 7 rotas RESTful automaticamente — ideal para CRUD completo. $routes->presenter() é variante para formulários HTML (usa new/edit em vez de JSON). only limita os métodos gerados. O controller deve estender ResourceController para API ou ResourcePresenter para HTML.

Rotas nomeadas
// Definir nome:
$routes->get('produtos/(:num)', 'Produto::ver/$1',
    ['as' => 'produto.ver']
);
$routes->get('perfil', 'Perfil::index',
    ['as' => 'perfil']
);

// Usar nome em redirecionamentos:
return redirect()->route('produto.ver', [$id]);

// Gerar URL na view:
<a href="<?= url_to('produto.ver', $p['id']) ?>">
    Ver produto
</a>

<a href="<?= url_to('perfil') ?>">Meu Perfil</a>

// Vantagem: mudar a URL sem quebrar links
// (basta alterar a rota, nomes mantêm-se)

Rotas nomeadas (['as' => 'nome']) desacoplam URLs de links. url_to() gera a URL a partir do nome e parâmetros. redirect()->route() redireciona por nome. Se a URL mudar, só altera a definição da rota — todos os links atualizam automaticamente. Essencial para manutenção e refatoração sem quebrar referências.

Error handling
// Lançar 404:
throw \CodeIgniter\Exceptions\PageNotFoundException
    ::forPageNotFound('Produto não encontrado');

// Erro custom:
throw new \RuntimeException('Erro no pagamento', 500);

// Páginas de erro custom:
// app/Views/errors/html/error_404.php
// app/Views/errors/html/error_exception.php
// app/Views/errors/html/production.php

// Handler global (app/Config/Exceptions.php):
// Personalizar resposta de erro

// Log de erros:
log_message('error', 'Falha no pagamento: ' . $e->getMessage());

// Em produção:
// Erros mostram página genérica (sem detalhes)

PageNotFoundException retorna 404 com página custom (error_404.php). Em development, erros mostram stack trace completa; em production, página genérica. Páginas de erro ficam em app/Views/errors/html/. log_message() regista em writable/logs/. Nunca expor detalhes de erro em produção (segurança).

Controller básico
<?php
namespace App\Controllers;

class Produto extends BaseController
{
    public function index()
    {
        $model = new \App\Models\ProdutoModel();
        $data['produtos'] = $model->findAll();
        return view('produtos/lista', $data);
    }

    public function ver($id)
    {
        $model = new \App\Models\ProdutoModel();
        $data['produto'] = $model->find($id);

        if (!$data['produto']) {
            throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
        }

        return view('produtos/detalhe', $data);
    }
}

Controllers estendem BaseController e contêm métodos de ação. return view() renderiza template com dados. PageNotFoundException retorna 404 automaticamente. O controller orquestra: recebe request, chama model, passa dados à view. Manter lógica de negócio nos models/services, não no controller.

Filtros de rota
// Filtro em rota individual:
$routes->get('admin', 'Admin::index',
    ['filter' => 'auth']
);

// Múltiplos filtros:
$routes->get('admin/relatorios', 'Admin::Relatorios',
    ['filter' => ['auth', 'admin']
);

// Filtro com parâmetros:
$routes->get('api/dados', 'Api::dados',
    ['filter' => 'throttle:60']
);

// Filtros globais (Config/Filters.php):
public array $globals = [
    'before' => ['csrf', 'honeypot'],
    'after' => ['toolbar', 'secureheaders'],
];

// Filtro por padrão URI:
public array $filters = [
    'auth' => ['before' => ['admin/*']],
];

Filtros são middleware do CI4 — executam antes (before) e/ou depois (after) do controller. Podem ser aplicados por rota, grupo, padrão URI ou globalmente. csrf e honeypot são built-in. Filtros custom implementam FilterInterface. Podem receber parâmetros (throttle:60). Ideais para autenticação, logging e rate limiting.

Route constraints
// Restringir por hostname:
$routes->get('docs', 'Docs::index',
    ['hostname' => 'docs.meusite.com']
);

// Restringir por subdomínio:
$routes->group('', ['hostname' => 'api.*'],
    function ($routes) {
        $routes->get('users', 'Api\Users::index');
    }
);

// Regex custom no placeholder:
$routes->get('users/([a-z-]+)', 'Users::ver/$1');

// Prioridade de rotas:
$routes->setPrioritize();
// Rotas mais específicas primeiro

// Verificar rota atual:
$route = service('router')->getRouteName();
$controller = service('router')->controllerName();
$method = service('router')->methodName();

Rotas podem ser restringidas por hostname (subdomínios, multi-site). Regex custom nos placeholders permite padrões complexos. setPrioritize() ativa prioridade por especificidade. service('router') fornece informação da rota atual (útil em layouts para menus ativos). Rotas são avaliadas na ordem — definir específicas antes de genéricas.

Grupos e namespaces
// Agrupar com prefixo:
$routes->group('admin',
    ['namespace' => 'App\Controllers\Admin'],
    function ($routes) {
        $routes->get('dashboard', 'Dashboard::index');
        $routes->get('users', 'Users::index');
        $routes->get('settings', 'Settings::index');
    }
);
// URLs: /admin/dashboard, /admin/users

// Grupo com filtro (middleware):
$routes->group('api',
    ['filter' => 'auth:api'],
    function ($routes) {
        $routes->get('perfil', 'Api\Perfil::show');
    }
);

// Sub-grupos:
$routes->group('admin', function ($routes) {
    $routes->group('users', function ($routes) {
        $routes->get('/', 'Admin\Users::index');
    });
});

$routes->group() agrupa rotas com prefixo URI, namespace e filtros partilhados. Evita repetição de prefixos e aplica middleware a múltiplas rotas. Sub-grupos permitem hierarquia (/admin/users). O namespace no grupo evita qualificar cada controller. Filtros no grupo aplicam-se a todas as rotas internas.

Auto-routing
// app/Config/Routes.php
// Auto-routing (CI4.5+):
$routes->setAutoRoute(true);

// URL: /produtos/listar
// -> App\Controllers\Produtos::listar()

// URL: /admin/users/index
// -> App\Controllers\Admin\Users::index()

// Desativar (recomendado em produção):
$routes->setAutoRoute(false);

// Auto-routing com tradução de namespace:
$routes->setAutoRoute(true);
$routes->setTranslateURIDashes(true);
// /meus-produtos -> MeusProdutos controller

// Prioridade: rotas definidas > auto-route

Auto-routing mapeia URIs diretamente para controllers/métodos sem definição explícita. Convenção: /controller/metodo/params. setTranslateURIDashes converte hífens em CamelCase. Em produção, é recomendado desativar (setAutoRoute(false)) e definir rotas explicitamente — mais seguro e documentado. Rotas definidas têm prioridade sobre auto-routing.

Modelos e BD


12 cards
Criar Model
<?php
namespace App\Models;

use CodeIgniter\Model;

class ProdutoModel extends Model
{
    protected $table = 'produtos';
    protected $primaryKey = 'id';
    protected $allowedFields = ['nome', 'preco', 'categoria'];
    protected $useTimestamps = true;
    protected $createdField = 'created_at';
    protected $updatedField = 'updated_at';
    protected $returnType = 'array';
    protected $useSoftDeletes = false;
}

// Criar via CLI:
// php spark make:model Produto

O Model do CI4 configura-se via propriedades: $table, $primaryKey, $allowedFields (mass assignment seguro). $useTimestamps gere created_at/updated_at automaticamente. $returnType define se retorna array ou objeto. php spark make:model gera o ficheiro automaticamente.

Paginação
// No controller:
public function index()
{
    $model = new ProdutoModel();
    $data['produtos'] = $model
        ->orderBy('nome', 'ASC')
        ->paginate(10);
    $data['pager'] = $model->pager;
    return view('produtos/lista', $data);
}

// Na view:
<?php foreach ($produtos as $p): ?>
    <p><?= esc($p['nome']) ?></p>
<?php endforeach; ?>

<?= $pager->links() ?>

// Links com grupo:
<?= $pager->links('grupo1') ?>

// Simple pagination (anterior/próximo):
<?= $pager->simpleLinks() ?>

paginate(10) retorna 10 registos e configura o pager automaticamente. $model->pager fornece o objeto de paginação. $pager->links() renderiza links HTML (números, anterior, próximo). Lê a página da query string (?page=2). Suporta múltiplos grupos de paginação na mesma página. Integra-se com Query Builder.

Relações entre Models
// CI4 não tem Eloquent-style relations
// Usar joins ou queries no Model:

class PedidoModel extends Model
{
    protected $table = 'pedidos';

    public function comItens(int $pedidoId): array
    {
        return $this->db->table('pedido_itens')
            ->where('pedido_id', $pedidoId)
            ->get()
            ->getResultArray();
    }

    public function comCliente()
    {
        return $this->select('pedidos.*, clientes.nome')
            ->join('clientes',
                'clientes.id = pedidos.cliente_id')
            ->findAll();
    }
}

// Alternativa: Entity com métodos:
// $pedido->itens() retorna itens do pedido

O CI4 não tem relações Eloquent-style (hasMany, belongsTo). Relações implementam-se com join() ou métodos custom no Model que fazem queries à tabela relacionada. Entities podem ter métodos que carregam dados relacionados. É mais explícito e performante — sem lazy loading surpresa. Para relações complexas, considerar pacotes como codeigniter4-relations.

CRUD com Model
$model = new ProdutoModel();

// Criar:
$model->insert([
    'nome' => 'TV Samsung',
    'preco' => 500,
    'categoria' => 'eletrónica'
]);
$id = $model->getInsertID();

// Ler:
$produto = $model->find($id);
$todos = $model->findAll();
$primeiros = $model->findAll(10); // limit

// Atualizar:
$model->update($id, ['preco' => 450]);

// Eliminar:
$model->delete($id);

// Verificar erros:
if (!$model->insert($dados)) {
    $erros = $model->errors();
}

Operações CRUD são métodos diretos: insert(), find(), findAll(), update(), delete(). getInsertID() retorna o ID gerado. errors() mostra erros de validação se falhar. $allowedFields protege contra mass assignment — só campos listados são inseridos/atualizados.

Query direta
$db = \Config\Database::connect();

// Query com binding (seguro):
$query = $db->query(
    "SELECT * FROM produtos WHERE preco > ? AND categoria = ?",
    [100, 'eletrónica']
);

// Resultados:
$resultados = $query->getResultArray(); // array
$objetos = $query->getResult();         // stdClass

// Uma linha:
$linha = $query->getRowArray();

// Named bindings:
$query = $db->query(
    "SELECT * FROM users WHERE email = :email:",
    ['email' => $email]
);

// Query Builder sem Model:
$builder = $db->table('produtos');
$builder->where('ativo', 1)->get()->getResultArray();

Database::connect() obtém a conexão. query() executa SQL direto com binding (? ou :nome:) — previne SQL injection. getResultArray() retorna arrays, getResult() objetos. Para queries complexas sem Model, use $db->table() como builder. Preferir Query Builder sempre que possível.

Transactions
$db = \Config\Database::connect();

// Transaction manual:
$db->transStart();

$db->table('pedidos')->insert($pedido);
$db->table('pedido_itens')->insertBatch($itens);
$db->table('produtos')
    ->where('id', $produtoId)
    ->set('stock', 'stock - 1', false)
    ->update();

$db->transComplete();

if ($db->transStatus() === false) {
    // Rollback automático
    log_message('error', 'Falha na transação');
}

// Transaction com try/catch:
try {
    $db->transBegin();
    // operações...
    $db->transCommit();
} catch (\Exception $e) {
    $db->transRollback();
    throw $e;
}

Transactions garantem atomicidade — todas as operações sucedem ou nenhuma. transStart()/transComplete() é o modo simples (rollback automático em falha). transBegin()/transCommit()/transRollback() dá controlo manual. Essencial para operações multi-tabela (pedidos + itens + stock). set('stock', 'stock - 1', false) evita escaping.

Query Builder
$model = new ProdutoModel();

// Condições encadeadas:
$resultados = $model
    ->where('preco >', 100)
    ->where('categoria', 'eletrónica')
    ->where('ativo', 1)
    ->orderBy('nome', 'ASC')
    ->limit(10)
    ->findAll();

// Like:
$model->like('nome', 'tv')
    ->orLike('descricao', 'televisão')
    ->findAll();

// Contar:
$total = $model->where('ativo', 1)
    ->countAllResults();

// whereIn:
$model->whereIn('id', [1, 2, 3])->findAll();

// whereNotIn:
$model->whereNotIn('categoria', ['removidos'])->findAll();

O Query Builder permite consultas encadeadas sem SQL direto — protege contra SQL injection automaticamente. where(), like(), orderBy(), limit() compõem a query. countAllResults() conta sem retornar dados. whereIn()/whereNotIn() para listas de valores. Métodos são chainable e legíveis.

Soft deletes
// No Model:
class ProdutoModel extends Model
{
    protected $useSoftDeletes = true;
    protected $deletedField = 'deleted_at';
}

// Eliminar (soft - marca deleted_at):
$model->delete($id);

// Incluir eliminados:
$model->withDeleted()->findAll();

// Só eliminados:
$model->onlyDeleted()->findAll();

// Eliminar permanentemente:
$model->delete($id, true);

// Restaurar:
$model->update($id, ['deleted_at' => null]);

// Na migration (campo necessário):
'deleted_at' => [
    'type' => 'DATETIME',
    'null' => true,
]

Soft deletes ($useSoftDeletes = true) marcam registos com deleted_at em vez de remover. delete() faz soft delete; delete($id, true) remove permanentemente. withDeleted() inclui eliminados nas queries. onlyDeleted() mostra só eliminados. Essencial para auditoria e recuperação de dados. Requer campo deleted_at na tabela.

Batch operations
$model = new ProdutoModel();

// Insert múltiplo:
$dados = [
    ['nome' => 'TV', 'preco' => 500],
    ['nome' => 'Rádio', 'preco' => 80],
    ['nome' => 'PC', 'preco' => 1200],
];
$model->insertBatch($dados);

// Update múltiplo:
$model->updateBatch($dados, 'nome');
// Atualiza onde 'nome' coincide

// Delete múltiplo:
$model->whereIn('id', [1, 2, 3])->delete();

// Chunk (processar em lotes):
$model->chunk(100, function ($row) {
    // Processar cada linha
    log_message('debug', $row['nome']);
});

// Contar total:
$total = $model->countAll();

insertBatch() insere múltiplas linhas numa query (muito mais rápido que loop de insert()). updateBatch() atualiza por chave de coincidência. chunk() processa registos em lotes sem carregar tudo em memória — essencial para milhares de registos. countAll() conta sem retornar dados. Operações batch são O(1) queries vs O(n).

Select e Join
// Select específico:
$model->select('id, nome, preco')
    ->findAll();

// Select com alias:
$model->select('produtos.*, categorias.nome as cat')
    ->join('categorias',
        'categorias.id = produtos.categoria_id')
    ->findAll();

// Join com tipo:
$model->select('p.*, c.nome as cliente')
    ->join('clientes c', 'c.id = p.cliente_id', 'left')
    ->findAll();

// Múltiplos joins:
$model->select('p.*, c.nome as cat, f.nome as forn')
    ->join('categorias c', 'c.id = p.categoria_id')
    ->join('fornecedores f', 'f.id = p.fornecedor_id')
    ->findAll();

// groupBy / having:
$model->select('categoria, COUNT(*) as total')
    ->groupBy('categoria')
    ->having('total >', 5)
    ->findAll();

select() limita colunas retornadas (evitar SELECT * em produção). join() aceita tabela, condição e tipo (inner, left, right). Múltiplos joins encadeiam-se. groupBy() e having() para agregações. Usar alias (as cat) para evitar conflitos de nomes entre tabelas.

Callbacks e eventos
class ProdutoModel extends Model
{
    protected $beforeInsert = ['hashSlug'];
    protected $afterFind = ['formatarPreco'];
    protected $beforeUpdate = ['atualizarSlug'];

    protected function hashSlug(array $data)
    {
        $data['data']['slug'] = url_title(
            $data['data']['nome'], '-', true
        );
        return $data;
    }

    protected function formatarPreco(array $data)
    {
        if (isset($data['data']['preco'])) {
            $data['data']['preco_fmt'] =
                number_format($data['data']['preco'], 2);
        }
        return $data;
    }
}

// Callbacks disponíveis:
// beforeInsert, afterInsert
// beforeUpdate, afterUpdate
// beforeFind, afterFind
// beforeDelete, afterDelete

Callbacks executam automaticamente antes/depois de operações do Model. $beforeInsert modifica dados antes de inserir (ex: gerar slug). $afterFind formata resultados (ex: formatar preço). Recebem e retornam $data (array com chave 'data'). Ideais para lógica transversal sem poluir controllers.

Entities
// app/Entities/Produto.php
namespace App\Entities;

use CodeIgniter\Entity\Entity;

class Produto extends Entity
{
    protected $casts = [
        'preco' => 'float',
        'ativo' => 'boolean',
        'tags' => 'json-array',
    ];

    // Mutator (setter):
    public function setNome(string $val): self
    {
        $this->attributes['nome'] = ucfirst($val);
        $this->attributes['slug'] = url_title($val, '-', true);
        return $this;
    }

    // Accessor (getter):
    public function getPrecoFormatado(): string
    {
        return number_format($this->preco, 2) . ' €';
    }
}

// No Model:
protected $returnType = \App\Entities\Produto::class;

// Uso:
$produto = $model->find(1);
echo $produto->preco_formatado;

Entities são objetos ricos que representam registos da BD. $casts converte tipos automaticamente (float, boolean, json-array). Mutators (setCampo) transformam ao definir; accessors (getCampo) ao ler. $returnType no Model faz find() retornar Entity em vez de array. Ideais para lógica de domínio e formatação.

Views e Layouts


11 cards
View simples
// Controller:
public function index()
{
    $data = [
        'titulo' => 'Meus Produtos',
        'produtos' => $model->findAll(),
    ];
    return view('produtos/lista', $data);
}

// app/Views/produtos/lista.php:
<h1><?= esc($titulo) ?></h1>

<?php if (empty($produtos)): ?>
    <p>Sem produtos.</p>
<?php else: ?>
    <?php foreach ($produtos as $p): ?>
        <div>
            <h3><?= esc($p['nome']) ?></h3>
            <p><?= esc($p['preco']) ?> €</p>
        </div>
    <?php endforeach; ?>
<?php endif; ?>

view('pasta/ficheiro', $data) renderiza template com dados extraídos como variáveis. Sempre usar esc() no output para prevenir XSS. Templates são PHP puro com sintaxe alternativa (foreach:/endforeach;). Views ficam em app/Views/ organizadas por subdiretórios. Não colocar lógica de negócio em views.

esc() e segurança
// SEMPRE escapar output:
<?= esc($nome) ?>
<?= esc($html, 'html') ?>
<?= esc($url, 'url') ?>
<?= esc($js, 'js') ?>
<?= esc($attr, 'attr') ?>

// Contextos:
// 'html' - conteúdo HTML (default)
// 'url'  - URLs
// 'js'   - JavaScript
// 'attr' - atributos HTML
// 'css'  - CSS

// CSRF em formulários:
<?= csrf_field() ?>
// Gera: <input type="hidden" name="csrf_token" ...>

// Meta tag (para AJAX):
<meta name="csrf-token" content="<?= csrf_hash() ?>">

// NUNCA:
// <?= $input_do_utilizador ?>  ← XSS!

esc() é a defesa contra XSS — escapa output conforme o contexto (html, url, js, attr, css). Usar SEMPRE em dados do utilizador. csrf_field() gera token CSRF em formulários. csrf_hash() para meta tags (AJAX). Sem esc(), a aplicação é vulnerável a injeção de script. Regra: todo output dinâmico passa por esc().

Filters de output
// app/Config/Filters.php
// Filtro de output (after):
public array $globals = [
    'after' => ['minify'],
];

// app/Filters/MinifyFilter.php
class MinifyFilter implements FilterInterface
{
    public function before(RequestInterface $request, $arguments = null) {}

    public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
    {
        $body = $response->getBody();
        $body = preg_replace('/\s+/', ' ', $body);
        $response->setBody($body);
        return $response;
    }
}

// Filtro de segurança (headers):
$response->setHeader('X-Content-Type-Options', 'nosniff');
$response->setHeader('X-Frame-Options', 'DENY');

Filtros after processam a resposta antes de enviar ao browser — ideais para minificar HTML, adicionar headers de segurança, comprimir output. FilterInterface tem before() e after(). Filtros globais aplicam-se a todas as rotas. Podem modificar body, headers e status code. Úteis para CSP, compressão e logging de respostas.

Layouts e secções
// Layout: app/Views/layouts/main.php
<!DOCTYPE html>
<html>
<head>
    <title><?= $this->renderSection('titulo') ?></title>
</head>
<body>
    <?= $this->include('partials/nav') ?>

    <main>
        <?= $this->renderSection('conteudo') ?>
    </main>

    <?= $this->renderSection('scripts') ?>
</body>
</html>

// Página:
<?= $this->extend('layouts/main') ?>

<?= $this->section('titulo') ?>Produtos<?= $this->endSection() ?>

<?= $this->section('conteudo') ?>
    <h1>Lista de Produtos</h1>
<?= $this->endSection() ?>

<?= $this->section('scripts') ?>
    <script src="/js/produtos.js"></script>
<?= $this->endSection() ?>

Template inheritance com extend() e section()/endSection(). O layout usa renderSection() como placeholders. Cada página define o conteúdo de cada secção. Suporta múltiplas secções (titulo, conteudo, scripts). Elimina repetição de HTML (header, footer, nav). Similar ao Blade do Laravel mas com sintaxe PHP pura.

old() e validação na view
// Repopular formulário após erro:
<input type="text" name="nome"
    value="<?= old('nome') ?>">

<textarea name="descricao">
    <?= old('descricao') ?>
</textarea>

<select name="categoria">
    <option value="1"
        <?= old('categoria') == '1' ? 'selected' : '' ?>>
        Eletrónica
    </option>
</select>

// Mostrar erros de validação:
<?php if (session('erro_nome')): ?>
    <span class="erro"><?= session('erro_nome') ?></span>
<?php endif; ?>

// Todos os erros:
<?php if (session('erros')): ?>
    <ul>
    <?php foreach (session('erros') as $e): ?>
        <li><?= esc($e) ?></li>
    <?php endforeach; ?>
    </ul>
<?php endif; ?>

old('campo') retorna o valor submetido anteriormente (flash) — repopula formulários após erro de validação. Erros são passados via session flash. session('erro_campo') mostra erro individual. Combinado com redirect()->back()->withInput() no controller. Essencial para UX — utilizador não perde dados ao submeter.

Data sharing entre views
// Partilhar dados com todas as views:
// No BaseController:
public function initController($request, $response, $logger)
{
    parent::initController($request, $response, $logger);

    // Disponível em todas as views:
    $this->data['user'] = session('user');
    $this->data['app_name'] = 'Minha App';
}

// Renderer global:
$view = \Config\Services::renderer();
$view->setData([
    'site_name' => 'Minha Loja',
    'ano' => date('Y'),
]);

// Na view:
<footer>
    <?= esc($site_name) ?> © <?= $ano ?>
</footer>

// View composer (dados por view):
$view->setData(['menu' => $this->getMenu()], 'layout');

Dados partilhados evitam passar as mesmas variáveis a cada view. setData() no renderer torna dados globais. No BaseController, definir $this->data com info do utilizador/sessão. View composers permitem dados específicos por template. Reduz repetição e centraliza dados comuns (user, menu, configurações).

Partials (include)
// Incluir fragmento:
<?= $this->include('partials/header') ?>
<?= $this->include('partials/nav') ?>

<main>
    <?= $conteudo ?>
</main>

<?= $this->include('partials/footer') ?>

// Include com dados:
<?= $this->include('partials/card', ['produto' => $p]) ?>

// Include com opções:
<?= $this->include('partials/alert', [
    'tipo' => 'sucesso',
    'msg' => 'Guardado!'
], ['saveData' => false]) ?>

// partials/alert.php:
<div class="alert alert-<?= $tipo ?>">
    <?= esc($msg) ?>
</div>

$this->include() insere fragmentos de view reutilizáveis (header, footer, cards, alerts). Aceita dados como segundo argumento. Terceiro argumento são opções (saveData, cache). Partials ficam em app/Views/partials/. Combinados com layouts, eliminam toda a repetição de HTML. Mais simples que componentes de frameworks JS.

View cells
// app/Cells/MenuCell.php
namespace App\Cells;

use CodeIgniter\View\Cells\Cell;

class MenuCell extends Cell
{
    protected array $links;

    public function mount(): void
    {
        $this->links = [
            ['url' => '/', 'label' => 'Início'],
            ['url' => '/produtos', 'label' => 'Produtos'],
        ];
    }
}

// View: app/Cells/menu_view.php
<nav>
    <?php foreach ($links as $link): ?>
        <a href="<?= base_url($link['url']) ?>">
            <?= esc($link['label']) ?>
        </a>
    <?php endforeach; ?>
</nav>

// Uso em qualquer view:
<?= view_cell('App\Cells\MenuCell') ?>

View Cells são mini-controllers para views — encapsulam lógica e dados de componentes reutilizáveis. mount() carrega dados (como um controller). A view da cell é renderizada isoladamente. view_cell() invoca em qualquer template. Ideais para menus, sidebars, widgets que precisam de dados da BD. Substituem include + query manual.

Assets e URLs
// Estrutura recomendada:
// public/assets/css/style.css
// public/assets/js/app.js
// public/assets/img/logo.png

// Na view:
<link rel="stylesheet"
    href="<?= base_url('assets/css/style.css') ?>">
<script
    src="<?= base_url('assets/js/app.js') ?>"></script>
<img src="<?= base_url('assets/img/logo.png') ?>"
    alt="Logo">

// Com versionamento (cache busting):
<link rel="stylesheet"
    href="<?= base_url('assets/css/style.css?v=' . filemtime(PUBLICPATH . 'assets/css/style.css')) ?>">

// Favicon:
<link rel="icon"
    href="<?= base_url('favicon.ico') ?>">

// NUNCA usar caminhos relativos:
// <link href="/css/style.css"> ← quebrar em subdirs

Assets ficam em public/assets/ (acessíveis pelo browser). Sempre usar base_url() para gerar URLs — funciona em subdiretórios e domínios diferentes. Cache busting com filemtime() ou hash no nome do ficheiro. Nunca caminhos relativos (/css/) — quebram se a app não estiver na raiz. Organizar por tipo (css, js, img).

Helpers de URL e Form
// Carregar helpers:
helper('url');
helper(['form', 'html']);

// URL helpers:
base_url()                    // http://localhost:8080/
base_url('produtos')          // .../produtos
site_url('admin/painel')      // com index.php se configurado
current_url()                 // URL atual
previous_url()                // URL anterior
uri_string()                  // segmento URI

// Form helpers:
echo form_open('produtos/salvar');
echo form_input('nome', old('nome'));
echo form_textarea('descricao');
echo form_dropdown('cat', $opcoes, $selected);
echo form_submit('', 'Guardar');
echo form_close();

// anchor:
echo anchor('produtos/1', 'Ver', ['class' => 'btn']);

Helpers são funções auxiliares carregadas com helper(). base_url() gera URLs absolutas. form_open() cria <form> com CSRF token automático. form_input(), form_dropdown() geram campos HTML. anchor() cria links com atributos. Helpers de form integram com validação via old().

Caching de views
// Cache de view (em segundos):
return view('produtos/lista', $data, ['cache' => 300]);

// Cache com chave custom:
return view('pagina', $data, [
    'cache' => 600,
    'cache_name' => 'pagina_home'
]);

// Invalidar cache:
$cache = \Config\Services::cache();
$cache->delete('pagina_home');

// Cache de página inteira (no controller):
public function index()
{
    // Cache por 5 minutos:
    $this->cachePage(300);
    return view('home');
}

// Configurar driver de cache:
// app/Config/Cache.php
public string $handler = 'file';
// Opções: file, memcached, redis, predis

Views podem ser cacheadas com a opção ['cache' => segundos] — evita re-renderização. cachePage() cacheia a resposta inteira (ideal para páginas estáticas). cache_name permite invalidação manual. Drivers: file (default), memcached, redis. Cache de views é por template+dados — mudanças nos dados invalidam automaticamente.

Validação e Formulários


10 cards
Regras no Controller
public function store()
{
    $regras = [
        'nome' => 'required|min_length[3]|max_length[100]',
        'email' => 'required|valid_email|is_unique[users.email]',
        'preco' => 'required|numeric|greater_than[0]',
    ];

    if (!$this->validate($regras)) {
        return redirect()->back()
            ->withInput()
            ->with('erros', $this->validator->getErrors());
    }

    // Dados válidos:
    $dados = $this->request->getPost();
    $model->insert($dados);
    return redirect()->to('/produtos')
        ->with('sucesso', 'Criado!');
}

$this->validate() verifica o input contra regras pipe-separated. Se falhar, getErrors() retorna mensagens. redirect()->back()->withInput() preserva dados do formulário. Regras são strings com | como separador. is_unique verifica unicidade na BD. Sempre validar antes de inserir/atualizar.

Upload de ficheiros
$ficheiro = $this->request->getFile('imagem');

if ($ficheiro->isValid() && !$ficheiro->hasMoved()) {
    // Nome aleatório (seguro):
    $nome = $ficheiro->getRandomName();

    // Mover para pasta:
    $ficheiro->move(WRITEPATH . '../public/uploads', $nome);

    // Informações:
    $original = $ficheiro->getClientName();
    $ext = $ficheiro->getExtension();
    $size = $ficheiro->getSize(); // bytes
    $mime = $ficheiro->getMimeType();
}

// Validação de upload:
$regras = [
    'imagem' => [
        'label' => 'Imagem',
        'rules' => 'uploaded[imagem]|max_size[imagem,2048]|is_image[imagem]|mime_in[imagem,image/jpg,image/png]',
    ],
];

getFile() obtém o ficheiro enviado. isValid() verifica sucesso do upload. getRandomName() gera nome seguro (evita path traversal). move() transfere para destino. Validação com uploaded, max_size (KB), is_image, mime_in. Sempre validar tipo e tamanho. Nunca usar nome original diretamente.

Sanitização de input
// Filtros PHP nativos:
$email = filter_var($input, FILTER_SANITIZE_EMAIL);
$url = filter_var($input, FILTER_SANITIZE_URL);
$int = filter_var($input, FILTER_SANITIZE_NUMBER_INT);

// No request do CI4:
$email = $this->request->getPost('email', FILTER_SANITIZE_EMAIL);

// Limpar HTML:
$limpo = strip_tags($input);
$seguro = htmlspecialchars($input, ENT_QUOTES, 'UTF-8');

// Trim e normalizar:
$nome = trim($this->request->getPost('nome'));
$nome = preg_replace('/\s+/', ' ', $nome);

// Escapar para contexto:
esc($dado, 'html');  // output HTML
esc($dado, 'url');   // URLs
esc($dado, 'js');    // JavaScript
esc($dado, 'attr');  // atributos

// Regra: validar > sanitizar > escapar

Sanitização limpa input antes de processar: FILTER_SANITIZE_EMAIL remove caracteres inválidos, strip_tags() remove HTML, trim() remove espaços. esc() escapa no output (não no input). Estratégia: validar (rejeitar inválido), sanitizar (limpar), escapar no output. Nunca confiar em sanitização como substituto de validação.

Regras no Model
class ProdutoModel extends Model
{
    protected $validationRules = [
        'nome' => 'required|min_length[3]|max_length[200]',
        'preco' => 'required|numeric|greater_than[0]',
        'email' => 'permit_empty|valid_email',
    ];

    protected $validationMessages = [
        'nome' => [
            'required' => 'O nome é obrigatório',
            'min_length' => 'Mínimo 3 caracteres',
        ],
        'preco' => [
            'required' => 'Indique o preço',
            'numeric' => 'Deve ser numérico',
        ],
    ];
}

// Validação automática em insert/update:
if (!$model->insert($dados)) {
    $erros = $model->errors();
}

Regras no Model ($validationRules) validam automaticamente em insert()/update(). $validationMessages personaliza mensagens por campo/regra. Se a validação falhar, a operação é abortada e errors() retorna os erros. Centraliza validação no Model — controllers ficam mais limpos. permit_empty permite vazio mas valida se preenchido.

Mensagens custom
// Mensagens por campo e regra:
$regras = [
    'nome' => [
        'label' => 'Nome do Produto',
        'rules' => 'required|min_length[3]',
        'errors' => [
            'required' => '{field} é obrigatório',
            'min_length' => '{field} deve ter pelo menos {param} caracteres',
        ],
    ],
    'email' => [
        'label' => 'Email',
        'rules' => 'required|valid_email|is_unique[users.email]',
        'errors' => [
            'is_unique' => 'Este {field} já está registado',
        ],
    ],
];

if (!$this->validate($regras)) {
    $erros = $this->validator->getErrors();
}

// Placeholders: {field}, {param}, {value}

Mensagens custom usam array com label, rules e errors. Placeholders: {field} (nome do campo), {param} (parâmetro da regra), {value} (valor submetido). label substitui o nome técnico por texto legível. Essencial para UX — mensagens claras ajudam o utilizador a corrigir erros.

Validação condicional
// Regras condicionais (if/else no controller):
$regras = ['tipo' => 'required|in_list[fisica,juridica]'];

if ($this->request->getPost('tipo') === 'fisica') {
    $regras['cpf'] = 'required|exact_length[11]|numeric';
} else {
    $regras['cnpj'] = 'required|exact_length[14]|numeric';
}

// required_if (CI 4.4+):
$regras = [
    'pais' => 'required',
    'estado' => 'required_if[pais,Brasil]',
    'nif' => 'required_if[tipo,juridica]',
];

// required_with / required_without:
$regras = [
    'telefone' => 'required_with[email]',
    'fax' => 'required_without[telefone]',
];

// Permitir campos opcionais:
$regras = [
    'website' => 'permit_empty|valid_url',
];

Validação condicional aplica regras diferentes conforme o input. required_if[campo,valor] exige campo se outro tiver valor específico. required_with/required_without dependem de presença de outros campos. permit_empty permite vazio mas valida se preenchido. Para lógica complexa, construir regras dinamicamente no controller com if/else.

Regras disponíveis
// Presença:
required, permit_empty

// Tamanho:
min_length[n], max_length[n], exact_length[n]

// Tipo:
alpha, alpha_numeric, alpha_dash, alpha_numeric_space
numeric, integer, decimal

// Formato:
valid_email, valid_url, valid_ip, valid_json
valid_date, valid_cc_num

// Comparação:
matches[campo], differs[campo]
greater_than[n], less_than[n]
greater_than_equal_to[n], less_than_equal_to[n]

// BD:
is_unique[tabela.campo]
is_not_unique[tabela.campo]

// Outros:
in_list[a,b,c], regex_match[/pattern/]
uploaded[campo], max_size[campo,n]

Regras cobrem presença (required), tamanho (min_length), tipo (numeric, valid_email), comparação (matches, greater_than) e BD (is_unique). in_list valida contra lista de valores. regex_match para padrões custom. uploaded/max_size para ficheiros. Regras encadeiam com |.

Regras custom
// app/Validation/RegrasCustom.php
namespace App\Validation;

class RegrasCustom
{
    public function maior_que_zero(
        ?string $valor, string &$erro = null
    ): bool {
        if ((float) $valor <= 0) {
            $erro = 'O valor deve ser positivo';
            return false;
        }
        return true;
    }

    public function sem_palavroes(
        ?string $valor, string &$erro = null
    ): bool {
        $banidas = ['spam', 'scam'];
        foreach ($banidas as $p) {
            if (str_contains(strtolower($valor), $p)) {
                $erro = "Contém palavra proibida: $p";
                return false;
            }
        }
        return true;
    }
}

// Registar em Config/Validation.php:
public array $ruleSets = [
    \App\Validation\RegrasCustom::class,
];

// Uso: 'preco' => 'required|maior_que_zero'

Regras custom são métodos em classes registadas em Config/Validation.php. Recebem o valor e referência a $erro (mensagem). Retornam true/false. O nome do método é o nome da regra. Permitem validação de lógica de negócio específica. Registar no array $ruleSets. Usar como qualquer regra built-in.

Receber input
// POST:
$nome = $this->request->getPost('nome');
$todos = $this->request->getPost();

// GET / query string:
$q = $this->request->getGet('q');
$pagina = $this->request->getGet('page');

// Qualquer método (POST ou GET):
$valor = $this->request->getVar('campo');

// JSON body:
$data = $this->request->getJSON(true); // assoc

// Com filtro PHP:
$email = $this->request->getPost('email', FILTER_SANITIZE_EMAIL);
$idade = $this->request->getPost('idade', FILTER_VALIDATE_INT);

// Múltiplos valores (checkboxes):
$cores = $this->request->getPost('cores'); // array

// Verificar existência:
if ($this->request->getPost('nome') !== null) { }

getPost() acede a dados POST, getGet() a query string, getVar() a qualquer método. getJSON(true) parseia body JSON como array associativo. Filtros PHP (FILTER_SANITIZE_EMAIL) sanitizam input. Nunca confiar em input do utilizador — sempre validar e escapar. getPost() sem argumento retorna todos os dados.

Validação de arrays
// Validar campos de array (formulários dinâmicos):
$regras = [
    'itens.*.nome' => 'required|min_length[2]',
    'itens.*.quantidade' => 'required|integer|greater_than[0]',
    'itens.*.preco' => 'required|numeric',
];

// HTML:
// <input name="itens[0][nome]">
// <input name="itens[0][quantidade]">
// <input name="itens[1][nome]">

// Validar array simples:
$regras = [
    'cores' => 'required',
    'cores.*' => 'alpha|max_length[20]',
];

// Mensagens para arrays:
$mensagens = [
    'itens.*.nome' => [
        'required' => 'Nome do item é obrigatório',
    ],
];

// Erros incluem índice:
// "itens.0.nome" => "Nome do item é obrigatório"

Wildcard * valida cada elemento de arrays — ideal para formulários dinâmicos (múltiplos itens). itens.*.nome aplica a regra a todos os elementos. Erros incluem o índice (itens.0.nome). Funciona com arrays simples (cores.*) e aninhados. Essencial para formulários de linhas repetidas (faturas, carrinhos).

Segurança e Sessões


10 cards
CSRF Protection
// Ativar globalmente (Config/Filters.php):
public array $globals = [
    'before' => ['csrf'],
];

// Configurar (Config/Security.php):
public string $tokenName = 'csrf_token';
public string $headerName = 'X-CSRF-TOKEN';
public int $expires = 7200;
public bool $regenerate = true;

// No formulário:
<?= csrf_field() ?>

// Em AJAX (meta tag no layout):
<meta name="csrf-token" content="<?= csrf_hash() ?>">
<meta name="csrf-header" content="X-CSRF-TOKEN">

// jQuery:
$.ajaxSetup({
    headers: {
        'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
    }
});

CSRF protection é ativado globalmente via filtro before. csrf_field() gera token hidden em formulários. Para AJAX, usar meta tag + header X-CSRF-TOKEN. regenerate cria novo token a cada request (mais seguro). expires define validade em segundos. Sem CSRF, formulários são vulneráveis a cross-site request forgery.

Autenticação simples
// Controller de login:
public function login()
{
    $email = $this->request->getPost('email');
    $pass = $this->request->getPost('password');

    $model = new UserModel();
    $user = $model->where('email', $email)->first();

    if ($user && password_verify($pass, $user['password'])) {
        session()->set([
            'user_id' => $user['id'],
            'nome' => $user['nome'],
            'logged_in' => true,
        ]);
        return redirect()->to('/dashboard');
    }

    return redirect()->back()
        ->with('erro', 'Credenciais inválidas');
}

// Logout:
public function logout()
{
    session()->destroy();
    return redirect()->to('/login');
}

// Verificar em filtro:
if (!session('logged_in')) {
    return redirect()->to('/login');
}

Autenticação básica: verificar credenciais com password_verify(), guardar estado na sessão. session()->destroy() no logout. Filtro auth protege rotas. Nunca guardar password em sessão. Para produção, considerar pacotes como codeigniter4/shield (autenticação oficial do CI4) com remember-me, throttle e verificação de email.

Proteção de dados sensíveis
// Nunca expor em logs:
log_message('error', 'Login falhou para: ' . $email);
// NUNCA: log_message('debug', 'Password: ' . $pass);

// .env fora do git:
// .gitignore:
.env
*.pem
*.key

// Não expor em erros:
// production: mensagem genérica
// development: stack trace (só local)

// Headers de segurança:
$response->removeHeader('X-Powered-By');
$response->removeHeader('Server');

// Validar acesso a recursos:
public function download($id)
{
    $ficheiro = $model->find($id);
    // Verificar dono:
    if ($ficheiro['user_id'] !== session('user_id')) {
        throw PageNotFoundException::forPageNotFound();
    }
    return $this->response->download($path, null);
}

Nunca logar passwords, tokens ou dados sensíveis. .env e chaves fora do git. Em produção, erros são genéricos (sem stack trace). Remover headers que revelam tecnologia (X-Powered-By). Validar autorização em cada acesso a recurso (IDOR — Insecure Direct Object Reference). Verificar sempre se o utilizador é dono do recurso antes de retornar dados.

Sessões
$session = session();

// Definir:
$session->set('user_id', 42);
$session->set(['nome' => 'Ana', 'role' => 'admin']);

// Ler:
$id = $session->get('user_id');
$nome = session('nome'); // helper

// Verificar:
if ($session->has('user_id')) { }

// Remover:
$session->remove('temp');

// Destruir tudo:
$session->destroy();

// Flash (uma leitura):
$session->setFlashdata('msg', 'Sucesso!');
$msg = $session->getFlashdata('msg');

// Tempdata (expira em N segundos):
$session->setTempdata('codigo', $code, 300);

Sessões do CI4 são acedidas via session() ou $this->session. set()/get() para dados persistentes. setFlashdata() para mensagens de uma leitura (redirect). setTempdata() expira automaticamente. Configuração em Config/App.php (driver, cookie, expiração). Driver default: file. Alternativas: database, redis, memcached.

Honeypot e anti-spam
// Ativar honeypot (Config/Filters.php):
public array $globals = [
    'before' => ['honeypot'],
    'after' => ['honeypot'],
];

// Configurar (Config/Honeypot.php):
public bool $hidden = true;
public string $label = 'Preencha este campo';
public string $name = 'website';
public string $template = '<label>{label}</label><input type="text" name="{name}" value="">';

// Como funciona:
// 1. Campo invisível adicionado ao form
// 2. Bots preenchem (não veem CSS)
// 3. Se preenchido -> request rejeitado

// Rate limiting manual:
$throttle = \Config\Services::throttler();
if ($throttle->check('login', 5, MINUTE) === false) {
    return redirect()->back()
        ->with('erro', 'Tentativas demais. Aguarde.');
}

Honeypot adiciona campo invisível ao formulário — bots preenchem (não veem CSS), humanos não. Se preenchido, request é rejeitado silenciosamente. Zero impacto UX. Throttler limita tentativas por IP/ação (rate limiting). Combinar honeypot + CSRF + throttle para proteção robusta. Alternativa ao CAPTCHA sem fricção para utilizadores.

Segurança de cookies
// Configurar sessão (Config/App.php):
public string $sessionCookieName = 'ci_session';
public int $sessionExpiration = 7200;
public string $sessionSavePath = WRITEPATH . 'session';
public bool $sessionMatchIP = false;
public string $sessionTimeToUpdate = 300;
public bool $sessionRegenerateDestroy = true;

// Cookie seguro (Config/Cookie.php):
public string $prefix = '';
public int $expires = 0;
public string $path = '/';
public string $domain = '';
public bool $secure = true;    // só HTTPS
public bool $httponly = true;  // sem JS access
public string $samesite = 'Lax'; // anti-CSRF

// Regenerar sessão (após login):
session()->regenerate(true);

// Definir cookie manual:
$this->response->setCookie('preferencia', $valor, [
    'httponly' => true,
    'secure' => true,
]);

Cookies de sessão devem ser secure (só HTTPS), httponly (inacessível via JavaScript) e samesite = Lax (anti-CSRF). sessionRegenerateDestroy destrói sessão antiga ao regenerar. Regenerar sessão após login previne session fixation. sessionMatchIP adiciona segurança mas pode quebrar em redes móveis. Expiração de 2h é razoável.

Encriptação e hashing
// Encriptação simétrica:
$encrypter = \Config\Services::encrypter();
$cifrada = $encrypter->encrypt('dados secretos');
$original = $encrypter->decrypt($cifrada);

// Configurar chave (Config/Encryption.php):
public string $key = 'sua-chave-32-bytes-aqui!';
public string $driver = 'OpenSSL';

// Hash de passwords (NUNCA encrypt):
$hash = password_hash($password, PASSWORD_DEFAULT);

// Verificar password:
if (password_verify($input, $hash)) {
    // password correta
}

// Verificar se precisa rehash:
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
    $novoHash = password_hash($password, PASSWORD_DEFAULT);
}

// HMAC:
$assinatura = hash_hmac('sha256', $dados, $chave);

Encriptação (encrypter) é reversível — para dados que precisa ler depois. Hash (password_hash) é irreversível — para passwords. Nunca encriptar passwords. password_verify() compara hash sem expor a password. password_needs_rehash() atualiza hash quando o algoritmo muda. Chave de encriptação deve estar no .env, nunca no código.

Content Security Policy
// Ativar (Config/App.php):
public bool $CSPEnabled = true;

// Configurar (Config/ContentSecurityPolicy.php):
public $defaultSrc = 'self';
public $scriptSrc = ['self', 'cdn.jsdelivr.net'];
public $styleSrc = ['self', 'fonts.googleapis.com'];
public $imgSrc = ['self', 'data:', 'https:'];
public $fontSrc = ['fonts.gstatic.com'];
public $connectSrc = 'self';
public $objectSrc = 'none';
public $frameAncestors = 'none';

// Headers de segurança adicionais:
// Config/Filters.php -> 'secureheaders'
public array $globals = [
    'after' => ['secureheaders'],
];

// Headers adicionados:
// X-Content-Type-Options: nosniff
// X-Frame-Options: SAMEORIGIN
// X-XSS-Protection: 1; mode=block

CSP controla de onde o browser pode carregar recursos — previne XSS e injeção de scripts. defaultSrc = 'self' permite só recursos do próprio domínio. Whitelists para CDNs e fonts. secureheaders adiciona headers de segurança (nosniff, frame options). frameAncestors = 'none' previne clickjacking. Essencial para aplicações com dados sensíveis.

Filtros (Middleware)
// app/Filters/AuthFilter.php
namespace App\Filters;

use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;

class AuthFilter implements FilterInterface
{
    public function before(RequestInterface $request, $arguments = null)
    {
        if (!session()->get('logged_in')) {
            return redirect()->to('/login')
                ->with('erro', 'Faça login primeiro');
        }
    }

    public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
    {
        // Pós-processamento (opcional)
    }
}

// Registar (Config/Filters.php):
public array $aliases = [
    'auth' => \App\Filters\AuthFilter::class,
];

// Aplicar:
$routes->get('admin', 'Admin::index', ['filter' => 'auth']);

Filtros implementam FilterInterface com before() e after(). before() pode retornar redirect/response para abortar. Registar em Config/Filters.php com alias. Aplicar por rota, grupo ou globalmente. Ideais para autenticação, autorização, logging, rate limiting. $arguments recebe parâmetros do filtro (filter:auth[admin]).

Validação de permissões
// Filtro de roles:
class RoleFilter implements FilterInterface
{
    public function before(RequestInterface $request, $arguments = null)
    {
        $role = session('role');

        if (!in_array($role, $arguments)) {
            return redirect()->to('/dashboard')
                ->with('erro', 'Sem permissão');
        }
    }

    public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) {}
}

// Registar:
'role' => \App\Filters\RoleFilter::class,

// Aplicar com parâmetros:
$routes->group('admin', ['filter' => 'role:admin,superadmin'],
    function ($routes) {
        $routes->get('users', 'Admin\Users::index');
        $routes->delete('users/(:num)', 'Admin\Users::delete/$1');
    }
);

Filtro de roles verifica permissões via argumentos (role:admin,superadmin). $arguments recebe a lista de roles permitidos. Aplicar por grupo protege todas as rotas internas. Combinar com filtro auth (login primeiro, role depois). Para RBAC completo, considerar codeigniter4/shield com grupos e permissões. Nunca confiar só em esconder links — validar no servidor.

Recursos Avançados


10 cards
Cache
$cache = \Config\Services::cache();

// Guardar (chave, dados, segundos):
$cache->save('produtos_populares', $dados, 3600);

// Ler:
$valor = $cache->get('produtos_populares');
if ($valor === null) {
    $valor = $model->getPopulares();
    $cache->save('produtos_populares', $valor, 3600);
}

// Apagar:
$cache->delete('produtos_populares');

// Limpar tudo:
$cache->clean();

// Cache de página (no controller):
public function index()
{
    $this->cachePage(300); // 5 minutos
    // ...
}

// Configurar driver (Config/Cache.php):
public string $handler = 'file';
// Opções: file, memcached, redis, predis, wincache

Cache reduz queries repetidas e tempo de resposta. save() armazena com TTL (segundos). get() retorna null se expirado. cachePage() cacheia a resposta inteira. Drivers: file (default, sem config), redis/memcached (produção, partilhado). Invalidar cache ao atualizar dados. Padrão: verificar cache → se miss, query + save.

Email
$email = \Config\Services::email();

// Configurar (Config/Email.php):
// SMTP:
public string $protocol = 'smtp';
public string $SMTPHost = 'smtp.gmail.com';
public string $SMTPUser = 'user@gmail.com';
public string $SMTPPass = 'app-password';
public int $SMTPPort = 587;
public string $SMTPCrypto = 'tls';

// Enviar:
$email->setFrom('noreply@site.com', 'Minha App');
$email->setTo('user@mail.com');
$email->setCC('admin@site.com');
$email->setSubject('Confirmação de Registo');
$email->setMessage('<h1>Bem-vindo!</h1>');

// HTML:
$email->setMailType('html');

// Anexo:
$email->attach('/path/to/ficheiro.pdf');

if ($email->send()) {
    // enviado
} else {
    log_message('error', $email->printDebugger());
}

Serviço de email suporta SMTP, sendmail e mail(). Configuração em Config/Email.php ou .env. setMailType('html') para emails HTML. attach() adiciona anexos. printDebugger() mostra erros de envio. Para Gmail, usar App Password (não a password da conta). Em produção, considerar serviços como Mailgun/SendGrid via SMTP.

Time e datas
use CodeIgniter\I18n\Time;

// Agora:
$agora = Time::now();
$agora = Time::now('Europe/Lisbon', 'pt_PT');

// Criar:
$data = Time::create(2024, 12, 25, 10, 30);
$data = Time::parse('2024-12-25 10:30:00');

// Formatar:
echo $agora->format('d/m/Y H:i');    // 25/12/2024 10:30
echo $agora->toDateString();          // 2024-12-25
echo $agora->humanize();              // "há 2 horas"

// Manipular:
$amanha = $agora->addDays(1);
$ontem = $agora->subMonths(2);
$inicio = $agora->startOfMonth();

// Comparar:
if ($agora->isAfter($prazo)) { }
$diferenca = $agora->difference($outra);
echo $diferenca->getDays(); // dias

// Timezone:
$lisboa = $agora->setTimezone('Europe/Lisbon');

Time é a classe de datas do CI4 (wraps DateTime/Carbon-like). Time::now() com timezone e locale. format() formata, humanize() retorna texto legível ("há 2 horas"). Métodos fluent: addDays(), subMonths(), startOfMonth(). difference() calcula intervalo. Sempre usar timezone explícito. Configurar default em Config/App.php.

Eventos e hooks
// app/Config/Events.php
use CodeIgniter\Events\Events;

// Ouvir evento:
Events::on('DBQuery', function ($query) {
    log_message('debug', (string) $query);
});

Events::on('pre_system', function () {
    // Antes de qualquer controller
});

// Disparar evento custom:
Events::trigger('pedido_criado', $pedidoId, $total);

// Ouvir custom:
Events::on('pedido_criado', function ($id, $total) {
    // Enviar email de confirmação
    // Atualizar stock
    // Notificar admin
});

// Prioridade (menor = primeiro):
Events::on('pedido_criado', $fn1, 10);
Events::on('pedido_criado', $fn2, 50);

// Remover listener:
Events::removeListener('pedido_criado', $fn1);

Eventos permitem desacoplar lógica — disparar sem saber quem ouve. Events::on() regista listener, Events::trigger() dispara. Prioridade controla ordem de execução. Eventos built-in: pre_system, post_system, DBQuery. Ideais para notificações, logging, analytics sem acoplar ao controller. Similar a observers/pub-sub.

Serviços (DI)
// Serviços built-in:
$request = service('request');
$session = service('session');
$cache = service('cache');
$logger = service('logger');
$db = \Config\Database::connect();

// Serviço personalizado (Config/Services.php):
public static function pagamento(bool $getShared = true)
{
    if ($getShared) {
        return static::getSharedInstance('pagamento');
    }
    return new \App\Libraries\PagamentoService();
}

// Uso:
$pagamento = service('pagamento');
$pagamento->cobrar(49.99);

// Injeção em controllers:
class Loja extends BaseController
{
    protected $pagamento;

    public function initController($req, $res, $log)
    {
        parent::initController($req, $res, $log);
        $this->pagamento = service('pagamento');
    }
}

Serviços são singletons geridos pelo container DI do CI4. service('nome') obtém instância partilhada. Serviços custom em Config/Services.php. $getShared controla singleton vs nova instância. Ideais para bibliotecas, gateways de pagamento, APIs externas. Facilitam testing (mock de serviços). Todos os serviços core são acessíveis via service().

HTTP Client (CURLRequest)
$client = \Config\Services::curlrequest();

// GET:
$response = $client->get('https://api.exemplo.com/dados', [
    'headers' => ['Authorization' => 'Bearer ' . $token],
    'timeout' => 10,
]);

// POST JSON:
$response = $client->post('https://api.exemplo.com/users', [
    'json' => ['nome' => 'Ana', 'email' => 'ana@mail.com'],
    'headers' => ['Accept' => 'application/json'],
]);

// Resposta:
$statusCode = $response->getStatusCode();
$body = $response->getBody();
$dados = json_decode($body, true);

// Com autenticação:
$response = $client->request('GET', $url, [
    'auth' => ['user', 'pass'],
]);

// Tratamento de erros:
try {
    $response = $client->get($url);
} catch (\CodeIgniter\HTTP\Exceptions\HTTPException $e) {
    log_message('error', 'API falhou: ' . $e->getMessage());
}

CURLRequest é o HTTP client built-in do CI4 (baseado em CURL). Suporta GET, POST, PUT, DELETE com headers, JSON, auth e timeout. getStatusCode() e getBody() para processar resposta. json option serializa e define Content-Type automaticamente. Envolver em try/catch para erros de rede. Ideal para consumir APIs externas.

Migrations
// Criar migration:
php spark make:migration CriarProdutos

// app/Database/Migrations/2024-01-01-000000_CriarProdutos.php
public function up()
{
    $this->forge->addField([
        'id' => ['type' => 'INT', 'auto_increment' => true],
        'nome' => ['type' => 'VARCHAR', 'constraint' => 200],
        'preco' => ['type' => 'DECIMAL', 'constraint' => '10,2'],
        'ativo' => ['type' => 'TINYINT', 'default' => 1],
        'created_at' => ['type' => 'DATETIME', 'null' => true],
        'updated_at' => ['type' => 'DATETIME', 'null' => true],
    ]);
    $this->forge->addKey('id', true);
    $this->forge->addKey('nome');
    $this->forge->createTable('produtos');
}

public function down()
{
    $this->forge->dropTable('produtos');
}

// Executar:
php spark migrate
php spark migrate:rollback

Migrations versionam a estrutura da BD — up() cria, down() reverte. forge é o schema builder: addField(), addKey(), createTable(). php spark migrate aplica pendentes, migrate:rollback reverte. Essencial para trabalho em equipa e deploy consistente. Nunca alterar tabelas manualmente em produção.

Logging
// Níveis de log:
log_message('emergency', 'Sistema em baixo');
log_message('alert', 'Ação imediata necessária');
log_message('critical', 'Erro crítico');
log_message('error', 'Erro na operação');
log_message('warning', 'Algo inesperado');
log_message('notice', 'Evento normal significativo');
log_message('info', 'Informação geral');
log_message('debug', 'Dados de depuração');

// Configurar (Config/Logger.php):
public $threshold = 4; // 1-9 (4 = error+)
// 9 = tudo, 1 = só emergency

// Logs em: writable/logs/log-2024-01-15.log

// Log com contexto:
log_message('error', 'Falha no pagamento: {id}', [
    'id' => $pedidoId,
]);

// Handler custom (email, Slack, etc):
// Config/Logger.php -> $handlers

log_message() regista eventos com nível de severidade (PSR-3). $threshold controla quais níveis são gravados (9 = tudo, 4 = error+). Logs em writable/logs/ com data no nome. Placeholders ({id}) com contexto. Em produção, threshold 4 (só erros). Em desenvolvimento, 9 (tudo). Handlers custom podem enviar para email, Slack ou serviços externos.

Seeders
// Criar seeder:
php spark make:seeder ProdutosSeeder

// app/Database/Seeds/ProdutosSeeder.php
namespace App\Database\Seeds;

use CodeIgniter\Database\Seeder;

class ProdutosSeeder extends Seeder
{
    public function run()
    {
        $dados = [
            ['nome' => 'TV', 'preco' => 500],
            ['nome' => 'Rádio', 'preco' => 80],
            ['nome' => 'PC', 'preco' => 1200],
        ];
        $this->db->table('produtos')->insertBatch($dados);

        // Chamar outro seeder:
        $this->call('CategoriasSeeder');
    }
}

// Executar:
php spark db:seed ProdutosSeeder

// Com Faker:
$faker = \Faker\Factory::create();
$nome = $faker->name;

Seeders populam a BD com dados iniciais ou de teste. insertBatch() insere múltiplas linhas. $this->call() encadeia seeders. php spark db:seed executa. Faker (via devkit) gera dados realistas. Ideais para dados de referência (categorias, países) e desenvolvimento. Separar seeders de produção e desenvolvimento.

Localização (i18n)
// app/Language/pt/App.php
return [
    'bemVindo' => 'Bem-vindo, {0}!',
    'itens' => '{0, number} itens no carrinho',
    'erro' => [
        'naoEncontrado' => 'Página não encontrada',
        'semPermissao' => 'Acesso negado',
    ],
];

// app/Language/en/App.php
return [
    'bemVindo' => 'Welcome, {0}!',
];

// Uso:
echo lang('App.bemVindo', [$nome]);
echo lang('App.erro.naoEncontrado');

// Configurar locale:
// Config/App.php:
public string $defaultLocale = 'pt';
public bool $negotiateLocale = true;
public array $supportedLocales = ['pt', 'en', 'es'];

// Mudar em runtime:
service('request')->setLocale('en');

Traduções ficam em app/Language/{locale}/ como arrays PHP. lang('App.chave') retorna a tradução. Placeholders ({0}) substituem parâmetros. negotiateLocale deteta idioma do browser. supportedLocales limita idiomas disponíveis. Organizar por ficheiro (App, Validation, Errors). Essencial para aplicações multi-idioma.

API RESTful


10 cards
Controller RESTful
<?php
namespace App\Controllers;

use CodeIgniter\RESTful\ResourceController;

class ApiProdutos extends ResourceController
{
    protected $modelName = 'App\Models\ProdutoModel';
    protected $format = 'json';

    // GET /api/produtos
    public function index()
    {
        return $this->respond($this->model->findAll());
    }

    // GET /api/produtos/1
    public function show($id = null)
    {
        $produto = $this->model->find($id);
        if (!$produto) {
            return $this->failNotFound('Produto não encontrado');
        }
        return $this->respond($produto);
    }

    // POST /api/produtos
    public function create()
    {
        $dados = $this->request->getJSON(true);
        $id = $this->model->insert($dados);
        $produto = $this->model->find($id);
        return $this->respondCreated($produto);
    }

    // PUT /api/produtos/1
    public function update($id = null)
    {
        $dados = $this->request->getJSON(true);
        $this->model->update($id, $dados);
        return $this->respond($this->model->find($id));
    }

    // DELETE /api/produtos/1
    public function delete($id = null)
    {
        $this->model->delete($id);
        return $this->respondDeleted(['id' => $id]);
    }
}

ResourceController fornece métodos REST padronizados (index, show, create, update, delete). respond() retorna 200, respondCreated() retorna 201, failNotFound() retorna 404. O $format = 'json' define o Content-Type automaticamente. Rotas com $routes->resource('api/produtos') mapeia todos os verbos HTTP.

Paginação em API
public function index()
{
    $pagina = $this->request->getGet('page') ?? 1;
    $porPagina = $this->request->getGet('per_page') ?? 20;

    $produtos = $this->model
        ->orderBy('created_at', 'DESC')
        ->paginate($porPagina, 'default', $pagina);

    $pager = $this->model->pager;

    return $this->respond([
        'data' => $produtos,
        'meta' => [
            'total'       => $pager->getTotal(),
            'por_pagina'  => $porPagina,
            'pagina'      => $pager->getCurrentPage(),
            'total_paginas' => $pager->getPageCount(),
        ],
    ]);
}

paginate() limita resultados e calcula offsets automaticamente. Parâmetros: itens por página, grupo e número da página. O objecto $pager fornece metadados: getTotal() (total de registos), getCurrentPage(), getPageCount(). Incluir metadados na resposta permite ao cliente construir navegação. Parâmetros via query string (?page=2&per_page=50).

CORS em API
// Config/Filters.php — alias:
public array $aliases = [
    'cors' => \App\Filters\CorsFilter::class,
];

// App/Filters/CorsFilter.php:
public function before(RequestInterface $request, $arguments = null)
{
    header('Access-Control-Allow-Origin: *');
    header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
    header('Access-Control-Allow-Headers: Content-Type, Authorization');

    // Responder preflight OPTIONS:
    if ($request->getMethod() === 'options') {
        $response = service('response');
        $response->setStatusCode(200);
        return $response;
    }
}

// Aplicar a grupo:
$routes->group('api', ['filter' => 'cors'], function ($routes) {
    $routes->resource('produtos');
});

CORS permite que browsers de outros domínios acedam à API. Headers Access-Control-Allow-* definem origens, métodos e headers permitidos. Requests OPTIONS (preflight) devem retornar 200 vazio. Em produção, substituir * por domínios específicos. Aplicar como filtro no grupo api para não afectar rotas web.

Resource Routes
// Config/Routes.php:

// Mapeia CRUD completo:
$routes->resource('api/produtos', [
    'controller' => 'ApiProdutos',
]);
// Gera:
// GET    /api/produtos       → index()
// GET    /api/produtos/(:num) → show($1)
// POST   /api/produtos       → create()
// PUT    /api/produtos/(:num) → update($1)
// DELETE /api/produtos/(:num) → delete($1)

// Apenas alguns métodos:
$routes->resource('api/users', [
    'only' => ['index', 'show'],
]);

// Excluir métodos:
$routes->resource('api/posts', [
    'except' => ['delete'],
]);

// Websafe (form em vez de PUT/DELETE):
$routes->resource('api/itens', [
    'websafe' => true,
]);
// PUT → POST com _method=PUT

$routes->resource() gera as 5 rotas REST automaticamente. only limita métodos disponíveis, except exclui específicos. websafe converte PUT/DELETE em POST com campo _method (para formulários HTML que só suportam GET/POST). Alternativa: $routes->presenter() inclui new() e edit() para formulários.

Filtros e pesquisa
public function index()
{
    $builder = $this->model;

    // Pesquisa por texto:
    if ($busca = $this->request->getGet('q')) {
        $builder = $builder->like('nome', $busca);
    }

    // Filtro exacto:
    if ($status = $this->request->getGet('status')) {
        $builder = $builder->where('status', $status);
    }

    // Filtro de intervalo:
    if ($min = $this->request->getGet('preco_min')) {
        $builder = $builder->where('preco >=', $min);
    }

    // Ordenação:
    $ordenar = $this->request->getGet('sort') ?? 'created_at';
    $direcao = $this->request->getGet('dir') ?? 'DESC';
    $builder = $builder->orderBy($ordenar, $direcao);

    return $this->respond($builder->paginate(20));
}

Filtros são opcionais — aplicados apenas se o parâmetro existir na query string. like() para pesquisa parcial, where() para igualdade exacta. Encadear condições no $builder permite combinações dinâmicas. Validar nomes de coluna para ordenação (whitelist) evita SQL injection. Padrão RESTful: ?q=termo&status=ativo&sort=nome&dir=ASC.

Transformers / Formatação
// Formatar saída da API:
private function formatarProduto(array $produto): array
{
    return [
        'id'         => (int) $produto['id'],
        'nome'       => $produto['nome'],
        'preco'      => number_format($produto['preco'], 2, '.', ''),
        'categoria'  => $produto['categoria_nome'] ?? null,
        'links'      => [
            'self' => base_url('api/produtos/' . $produto['id']),
        ],
    ];
}

public function index()
{
    $produtos = $this->model->findAll();
    $dados = array_map([$this, 'formatarProduto'], $produtos);
    return $this->respond($dados);
}

// Com relações:
public function show($id = null)
{
    $produto = $this->model
        ->select('produtos.*, categorias.nome as categoria_nome')
        ->join('categorias', 'categorias.id = produtos.categoria_id', 'left')
        ->find($id);

    return $this->respond($this->formatarProduto($produto));
}

Transformers formatam dados antes de enviar — nunca expor a estrutura interna da base de dados. Converter tipos ((int)), formatar valores (number_format) e incluir links HATEOAS. array_map() aplica a transformação a colecções. Manter campos consistentes entre index e show. Para projectos maiores, criar classes Transformer dedicadas.

Respostas JSON
// Respostas com status codes:
return $this->respond($dados);           // 200
return $this->respondCreated($dados);    // 201
return $this->respondDeleted($dados);    // 200

// Erros:
return $this->fail('Erro genérico');              // 400
return $this->failUnauthorized('Token inválido'); // 401
return $this->failForbidden('Sem permissão');     // 403
return $this->failNotFound('Não encontrado');     // 404
return $this->failValidationError($erros);        // 422
return $this->failServerError('Erro interno');    // 500

// Resposta manual:
return $this->response
    ->setStatusCode(202)
    ->setJSON(['status' => 'aceite']);

// Formato da resposta:
// { "status": 200, "error": null, "messages": [], "data": {...} }

Métodos respond*() e fail*() padronizam a estrutura JSON com status, error, messages e data. Cada método define o HTTP status code correto automaticamente. failValidationError() aceita array de erros de validação. Para respostas customizadas, usar setStatusCode() + setJSON() directamente.

Autenticação com token
// Filtro de autenticação (App/Filters/AuthApi.php):
namespace App\Filters;

use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;

class AuthApi implements FilterInterface
{
    public function before(RequestInterface $request, $arguments = null)
    {
        $token = $request->getHeaderLine('Authorization');
        $token = str_replace('Bearer ', '', $token);

        if (empty($token)) {
            return service('response')
                ->setStatusCode(401)
                ->setJSON(['error' => 'Token ausente']);
        }

        $user = model('UserModel')
            ->where('api_token', hash('sha256', $token))
            ->first();

        if (!$user) {
            return service('response')
                ->setStatusCode(401)
                ->setJSON(['error' => 'Token inválido']);
        }

        // Guardar user para uso posterior:
        $request->user = $user;
    }

    public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
    {
    }
}

Filtros interceptam requests antes do controller. O token vem no header Authorization: Bearer xxx. Armazenar hash (não token plain) na base de dados. Retornar resposta 401 directamente no before() bloqueia o acesso. Registar em Config/Filters.php como alias e aplicar a rotas: $routes->group('api', ['filter' => 'authapi']).

Validação em API
public function create()
{
    $dados = $this->request->getJSON(true);

    $regras = [
        'nome'  => 'required|min_length[3]|max_length[100]',
        'email' => 'required|valid_email|is_unique[users.email]',
        'idade' => 'permit_empty|integer|greater_than[0]',
    ];

    if (!$this->validate($regras)) {
        return $this->failValidationError(
            $this->validator->getErrors()
        );
    }

    $id = $this->model->insert($dados);
    return $this->respondCreated(
        $this->model->find($id),
        'Recurso criado'
    );
}

Em APIs, $this->request->getJSON(true) converte o body JSON em array associativo. A validação usa as mesmas regras do formulário. failValidationError() retorna 422 com os erros detalhados em JSON. getErrors() retorna array de mensagens por campo. O segundo parâmetro de respondCreated() é a mensagem de sucesso.

Rate limiting
// Filtro simples de rate limit (App/Filters/RateLimit.php):
public function before(RequestInterface $request, $arguments = null)
{
    $ip = $request->getIPAddress();
    $cache = service('cache');
    $key = 'rate_' . $ip;

    $hits = $cache->get($key) ?? 0;
    $limite = 60; // requests por minuto

    if ($hits >= $limite) {
        return service('response')
            ->setStatusCode(429)
            ->setHeader('Retry-After', '60')
            ->setJSON(['error' => 'Limite excedido']);
    }

    $cache->save($key, $hits + 1, 60);
}

// Aplicar globalmente (Config/Filters.php):
public array $globals = [
    'before' => ['ratelimit'],
];

Rate limiting protege APIs contra abuso. Usa cache para contar requests por IP com TTL de 60 segundos. Ao exceder o limite, retorna 429 Too Many Requests com header Retry-After. Aplicar como filtro global ou por grupo de rotas. Para produção, preferir Redis (contador partilhado entre servidores). Headers informativos: X-RateLimit-Limit, X-RateLimit-Remaining.

CLI e Ferramentas


11 cards
Comando Spark
// spark é o CLI do CodeIgniter 4:
php spark

// Comandos built-in:
php spark serve          // Servidor de desenvolvimento
php spark routes         // Listar todas as rotas
php spark db:seed        // Executar seeders
php spark migrate        // Executar migrações
php spark migrate:rollback // Reverter migrações
php spark make:controller Nome  // Gerar controller
php spark make:model Nome       // Gerar model
php spark make:migration Nome   // Gerar migração
php spark make:command Nome     // Gerar comando
php spark make:filter Nome      // Gerar filtro
php spark make:seeder Nome      // Gerar seeder
php spark cache:clear    // Limpar cache
php spark db:table       // Listar tabelas

php spark é a interface CLI do CI4 (equivalente ao artisan do Laravel). serve inicia servidor na porta 8080. routes mostra todas as rotas registadas com verbos e handlers. make:* gera boilerplate com namespace correcto. migrate e db:seed gerem a base de dados. Todos os comandos aceitam --help para documentação.

Seeders e dados
// Criar: php spark make:seeder ProdutosSeeder
// App/Database/Seeds/ProdutosSeeder.php:

namespace App\Database\Seeds;

use CodeIgniter\Database\Seeder;

class ProdutosSeeder extends Seeder
{
    public function run()
    {
        $dados = [
            [
                'nome' => 'Produto A',
                'preco' => 29.90,
                'activo' => 1,
                'created_at' => date('Y-m-d H:i:s'),
            ],
            [
                'nome' => 'Produto B',
                'preco' => 49.90,
                'activo' => 1,
                'created_at' => date('Y-m-d H:i:s'),
            ],
        ];

        $this->db->table('produtos')->insertBatch($dados);

        // Chamar outro seeder:
        $this->call('CategoriasSeeder');
    }
}

// Executar:
// php spark db:seed ProdutosSeeder

Seeders populam a base de dados com dados iniciais. insertBatch() insere múltiplas linhas eficientemente. $this->call() encadeia seeders (ordem de execução). $this->db dá acesso à conexão. Para dados de teste, usar Faker (integrado no CI4). Executar com php spark db:seed NomeSeeder. Registar seeders no DatabaseSeeder para execução em conjunto.

Configuração de ambientes
// .env (raiz do projecto):
# CI_ENVIRONMENT = production | development | testing
CI_ENVIRONMENT = development

# Base URL:
app.baseURL = 'http://localhost:8080/'

# Database:
database.default.hostname = localhost
database.default.database = minha_app
database.default.username = root
database.default.password = ''
database.default.DBDriver = MySQLi

# Email:
email.protocol = smtp
email.SMTPHost = smtp.gmail.com
email.SMTPUser = user@gmail.com
email.SMTPPass = senha_app
email.SMTPPort = 587
email.SMTPCrypto = tls

# Em produção:
# CI_ENVIRONMENT = production
# (desactiva debugbar, mostra página de erro genérica)

O ficheiro .env configura o ambiente sem alterar código. CI_ENVIRONMENT controla debug: development mostra erros detalhados + debugbar, production esconde detalhes. Configs usam notação grupo.propriedade. Nunca commitar .env — usar .env.example como template. Em produção, definir baseURL, database e email com valores reais.

Criar comando custom
<?php
namespace App\Commands;

use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;

class GerarRelatorio extends BaseCommand
{
    protected $group = 'App';
    protected $name = 'relatorio:gerar';
    protected $description = 'Gera relatório mensal de vendas';
    protected $usage = 'relatorio:gerar [mes] [ano]';
    protected $arguments = [
        'mes' => 'Mês (1-12)',
        'ano' => 'Ano (4 dígitos)',
    ];
    protected $options = [
        '--formato' => 'Formato: csv, pdf, excel',
    ];

    public function run(array $params)
    {
        $mes = $params[0] ?? date('m');
        $ano = $params[1] ?? date('Y');
        $formato = CLI::getOption('formato') ?? 'csv';

        CLI::write("Gerando relatório: {$mes}/{$ano}");
        CLI::write("Formato: {$formato}", 'green');

        // Lógica do relatório...
        $total = model('VendaModel')
            ->where('MONTH(created_at)', $mes)
            ->where('YEAR(created_at)', $ano)
            ->selectSum('total')
            ->first();

        CLI::write("Total: R$ {$total['total']}", 'yellow');
        CLI::newLine();
    }
}

Comandos custom estendem BaseCommand em App/Commands/. $name define como invocar (php spark relatorio:gerar 06 2024). $arguments são posicionais, $options usam --flag. CLI::write() imprime com cor (green, yellow, red). CLI::getOption() lê flags. $group organiza no output de php spark.

Faker / Dados de teste
// Em Seeders (Faker integrado):
use Faker\Factory;

public function run()
{
    $faker = Factory::create('pt_BR');

    $dados = [];
    for ($i = 0; $i < 50; $i++) {
        $dados[] = [
            'nome'  => $faker->name(),
            'email' => $faker->unique()->safeEmail(),
            'telefone' => $faker->phoneNumber(),
            'morada' => $faker->address(),
            'empresa' => $faker->company(),
            'created_at' => $faker->dateTimeThisYear()->format('Y-m-d H:i:s'),
        ];
    }

    $this->db->table('clientes')->insertBatch($dados);
}

// Factories (CI4 built-in):
use CodeIgniter\Test\Fabricator;
use App\Models\UserModel;

$users = fabricate(UserModel::class, 10, [
    'activo' => 1,
]);

Faker gera dados realistas para testes e desenvolvimento. Locale pt_BR para dados em português. unique() evita duplicados. insertBatch() para performance com muitos registos. fabricate() é o helper do CI4 que combina Factory + Faker + Model. Nunca usar seeders com Faker em produção — apenas para desenvolvimento e testes.

Deploy e optimização
// Checklist de produção:

// 1. Ambiente:
CI_ENVIRONMENT = production

// 2. Cache de configurações:
php spark cache:clear

// 3. Optimizar autoloader (composer):
composer install --no-dev --optimize-autoloader

// 4. Estrutura no servidor:
// /public_html/  ← apontar document root aqui
//   index.php
//   assets/
// /app/          ← fora do document root
// /vendor/
// /writable/     ← permissões 755

// 5. index.php (apontar para ../):
// $pathsConfig = FCPATH . '../app/Config/Paths.php';

// 6. .htaccess (Apache):
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php/$1 [L]

// 7. Segurança:
// - Desactivar listagem de directórios
// - Proteger /writable e /app
// - HTTPS obrigatório

Em produção, o document root deve apontar para /public/ — o resto fica inacessível via web. CI_ENVIRONMENT = production desactiva debugging. composer install --no-dev exclui dependências de desenvolvimento. .htaccess reescreve URLs para o front controller. Proteger /writable e /app com regras de acesso. Usar HTTPS e headers de segurança.

CLI input/output
use CodeIgniter\CLI\CLI;

// Output com cores:
CLI::write('Sucesso!', 'green');
CLI::write('Atenção!', 'yellow');
CLI::write('Erro!', 'red');
CLI::error('Falha crítica');  // vermelho + stderr

// Input do utilizador:
$nome = CLI::prompt('Qual o seu nome?');
$email = CLI::prompt('Email:', null, 'required|valid_email');

// Escolha entre opções:
$tipo = CLI::prompt('Tipo:', ['admin', 'user', 'guest']);
// Mostra: [0] admin [1] user [2] guest

// Confirmação:
if (CLI::prompt('Continuar?', ['y', 'n']) === 'y') {
    // prosseguir
}

// Formatação:
CLI::newLine();
CLI::write(str_repeat('-', 40));
CLI::table($dados, ['ID', 'Nome', 'Email']);

// Progresso:
$progress = new \CodeIgniter\CLI\Progressbar(100);
for ($i = 0; $i <= 100; $i++) {
    $progress->update($i);
}

A classe CLI fornece I/O interactivo para comandos. prompt() aceita validação inline (regras do Validation). CLI::table() formata arrays em tabela ASCII. Cores: green, yellow, red, blue, magenta, cyan. CLI::error() escreve em stderr. Progressbar mostra progresso em loops longos. Input com validação repete a pergunta até valor válido.

Testing
// tests/unit/CalculadoraTest.php:
namespace Tests\Unit;

use CodeIgniter\Test\CIUnitTestCase;

class CalculadoraTest extends CIUnitTestCase
{
    public function testSomar()
    {
        $resultado = 2 + 3;
        $this->assertEquals(5, $resultado);
    }

    public function testDivisaoPorZero()
    {
        $this->expectException(\DivisionByZeroError::class);
        $resultado = 1 / 0;
    }
}

// Test de feature (HTTP):
namespace Tests\Feature;

use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Test\CIUnitTestCase;

class ApiTest extends CIUnitTestCase
{
    use FeatureTestTrait;

    public function testListarProdutos()
    {
        $result = $this->get('api/produtos');
        $result->assertOK();
        $result->assertJSONFragment(['nome' => 'Produto A']);
    }
}

// Executar: php spark test
// Ou: vendor/bin/phpunit

CI4 usa PHPUnit integrado. CIUnitTestCase é a classe base com helpers do framework. FeatureTestTrait permite testar rotas HTTP sem servidor (get(), post()). Assertions: assertOK() (200), assertJSONFragment(), assertStatus(). expectException() para erros. Configurar em phpunit.xml. Executar com php spark test.

Estrutura do projecto
// Estrutura CI4:
project/
├── app/
│   ├── Config/         // Configurações
│   │   ├── Routes.php  // Rotas
│   │   ├── Database.php
│   │   ├── Validation.php
│   │   └── Filters.php
│   ├── Controllers/    // Controllers
│   ├── Models/         // Models
│   ├── Views/          // Templates
│   ├── Database/
│   │   ├── Migrations/ // Migrações
│   │   └── Seeds/      // Seeders
│   ├── Filters/        // Filtros (middleware)
│   ├── Libraries/      // Classes auxiliares
│   └── Helpers/        // Funções helper
├── public/             // Document root
│   ├── index.php       // Front controller
│   └── assets/         // CSS, JS, imagens
├── writable/           // Logs, cache, uploads
├── tests/              // Testes
├── vendor/             // Dependências Composer
├── spark               // CLI
└── .env                // Config de ambiente

A estrutura segue MVC com separação clara. app/ contém toda a lógica. public/ é o único directório acessível via web (front controller index.php). writable/ armazena logs, cache e uploads (precisa de permissão de escrita). Filters/ são middleware. spark é o CLI. Configs em app/Config/ — uma classe por aspecto do sistema.

Migrações
// Criar: php spark make:migration criar_produtos
// App/Database/Migrations/2024-01-15-120000_criar_produtos.php:

namespace App\Database\Migrations;

use CodeIgniter\Database\Migration;

class CriarProdutos extends Migration
{
    public function up()
    {
        $this->forge->addField([
            'id' => ['type' => 'INT', 'auto_increment' => true],
            'nome' => ['type' => 'VARCHAR', 'constraint' => 200],
            'preco' => ['type' => 'DECIMAL', 'constraint' => '10,2'],
            'activo' => ['type' => 'TINYINT', 'default' => 1],
            'created_at' => ['type' => 'DATETIME', 'null' => true],
            'updated_at' => ['type' => 'DATETIME', 'null' => true],
        ]);
        $this->forge->addKey('id', true);
        $this->forge->addKey('activo');
        $this->forge->createTable('produtos');
    }

    public function down()
    {
        $this->forge->dropTable('produtos', true);
    }
}

// Executar:
// php spark migrate
// php spark migrate:rollback
// php spark migrate:refresh (rollback + migrate)

Migrações versionam o schema da base de dados. $this->forge é o builder de DDL. addField() define colunas com tipo, constraint e default. addKey() cria índices (segundo parâmetro true = primary key). up() aplica, down() reverte. Ficheiros ordenados por timestamp. migrate:refresh recria tudo do zero.

Debugging e profiling
// Debugbar (activar em .env):
// CI_ENVIRONMENT = development

// Toolbar de debug mostra:
// - Queries SQL com tempo
// - Rotas correspondidas
// - Views renderizadas
// - Headers da request
// - Logs e timeline

// Logging manual:
log_message('error', 'Falha ao processar pedido #' . $id);
log_message('info', 'User login: ' . $email);
log_message('debug', 'Dados: ' . print_r($dados, true));

// dd() e d():
dd($variavel);  // dump + die
d($variavel);   // dump (continua)

// Benchmark:
$benchmark = service('timer');
$benchmark->start('query');
$dados = $model->findAll();
$benchmark->stop('query');
echo $benchmark->getElapsedTime('query');

// Ver última query SQL:
echo $model->getLastQuery();

Em modo development, a Debugbar mostra queries, tempo, rotas e views automaticamente. log_message() grava em writable/logs/ com níveis (error, info, debug). dd() faz dump e para execução. service('timer') mede performance de trechos de código. getLastQuery() mostra o SQL gerado pelo Query Builder para debugging.