Referência v3
Voltar à plataforma

Módulo Usuario — Exemplo Completo

Exemplo prático com o módulo Usuario — simples, comentado e pronto para usar.

O que você vai construir

O módulo Usuario com registro, listagem, atualização e deleção — com Entity comentada, Repository, Service, Controller, Migration, Seeder e Rotas.



Estrutura de arquivos

src/Modules/Usuario/ ├── Controllers/ │ └── UsuarioController.php ├── Services/ │ └── UsuarioService.php ├── Repositories/ │ ├── UsuarioRepositoryInterface.php │ └── UsuarioRepository.php ├── Entities/ │ └── Usuario.php ├── Database/ │ ├── Migrations/ │ │ └── 001_create_usuarios.php │ ├── Seeders/ │ │ └── 001_admin_user.php │ └── connection.php └── Routes/ └── web.php # ⚠ Obrigatório para ter rotas HTTP

Passo 1 — Criar a estrutura

bash
php vupi make:module Usuario

Ou manualmente:

bash
mkdir -p src/Modules/Usuario/{Controllers,Services,Repositories,Entities,Database/Migrations,Database/Seeders,Routes}

Passo 2 — Entity

A Entity representa o domínio do usuário. Ela encapsula os dados e garante que nenhum objeto inválido seja criado — validações ficam aqui, não no Controller.

Arquivo: src/Modules/Usuario/Entities/Usuario.php

php
<?php

namespace Src\Modules\Usuario\Entities;

use DateTimeImmutable;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;

/**
 * Entity Usuario — representa um usuário do sistema.
 *
 * Regras de negócio ficam aqui: validações de e-mail, senha e nível de acesso.
 * O construtor é privado — use os métodos estáticos registrar() e reconstituir().
 */
final class Usuario
{
    // Níveis de acesso permitidos no sistema
    private const NIVEIS_VALIDOS = ['usuario', 'admin', 'moderador', 'admin_system'];

    // Construtor privado: força o uso das factories abaixo
    private function __construct(
        private readonly UuidInterface    $uuid,                   // Identificador único imutável
        private string                    $nomeCompleto,           // Nome completo do usuário
        private string                    $username,               // Login único (ex: joao.silva)
        private string                    $email,                  // E-mail único
        private string                    $senhaHash,              // Hash Argon2ID — nunca a senha em texto
        private string                    $nivelAcesso,            // Permissão: usuario | admin | admin_system
        private bool                      $ativo,                  // false = conta desativada
        private bool                      $verificadoEmail,        // true = e-mail confirmado
        private readonly DateTimeImmutable $criadoEm,              // Data de criação (imutável)
        private ?DateTimeImmutable        $atualizadoEm = null     // Última atualização
    ) {}

    // ── Factories ────────────────────────────────────────────────────────

    /**
     * Cria um novo usuário com validações.
     * Use este método ao registrar um usuário novo.
     */
    public static function registrar(
        string $nomeCompleto,
        string $username,
        string $email,
        string $senha,
        string $nivelAcesso = 'usuario'
    ): self {
        self::validarEmail($email);
        self::validarSenha($senha);
        self::validarNivel($nivelAcesso);

        return new self(
            uuid:            Uuid::uuid4(),                              // Gera UUID v4 aleatório
            nomeCompleto:    trim($nomeCompleto),
            username:        strtolower(trim($username)),                // Normaliza para minúsculas
            email:           strtolower(trim($email)),                   // Normaliza para minúsculas
            senhaHash:       password_hash($senha, PASSWORD_ARGON2ID),   // Hash seguro
            nivelAcesso:     $nivelAcesso,
            ativo:           true,
            verificadoEmail: false,
            criadoEm:        new DateTimeImmutable()
        );
    }

    /**
     * Reconstitui um usuário a partir dos dados do banco.
     * Use este método ao mapear uma linha do banco para a Entity.
     */
    public static function reconstituir(
        UuidInterface      $uuid,
        string             $nomeCompleto,
        string             $username,
        string             $email,
        string             $senhaHash,
        string             $nivelAcesso,
        bool               $ativo,
        bool               $verificadoEmail,
        DateTimeImmutable  $criadoEm,
        ?DateTimeImmutable $atualizadoEm = null
    ): self {
        return new self(
            $uuid, $nomeCompleto, strtolower(trim($username)),
            $email, $senhaHash, $nivelAcesso,
            $ativo, $verificadoEmail, $criadoEm, $atualizadoEm
        );
    }

    // ── Validações ────────────────────────────────────────────────────────

    private static function validarEmail(string $email): void
    {
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new \InvalidArgumentException('E-mail inválido.');
        }
    }

    private static function validarSenha(string $senha): void
    {
        if (strlen($senha) < 8) {
            throw new \InvalidArgumentException('Senha deve ter ao menos 8 caracteres.');
        }
    }

    private static function validarNivel(string $nivel): void
    {
        if (!in_array($nivel, self::NIVEIS_VALIDOS, true)) {
            throw new \InvalidArgumentException('Nível de acesso inválido.');
        }
    }

    // ── Comportamentos ────────────────────────────────────────────────────

    /** Verifica se a senha informada corresponde ao hash armazenado. */
    public function verificarSenha(string $senhaPlana): bool
    {
        return password_verify($senhaPlana, $this->senhaHash);
    }

    /** Altera a senha aplicando validação e gerando novo hash. */
    public function alterarSenha(string $novaSenha): void
    {
        self::validarSenha($novaSenha);
        $this->senhaHash    = password_hash($novaSenha, PASSWORD_ARGON2ID);
        $this->atualizadoEm = new DateTimeImmutable();
    }

    /** Promove o usuário para um novo nível de acesso. */
    public function promoverPara(string $nivel): void
    {
        self::validarNivel($nivel);
        $this->nivelAcesso  = $nivel;
        $this->atualizadoEm = new DateTimeImmutable();
    }

    public function ativar(): void    { $this->ativo = true;  $this->atualizadoEm = new DateTimeImmutable(); }
    public function desativar(): void { $this->ativo = false; $this->atualizadoEm = new DateTimeImmutable(); }

    // ── Getters ───────────────────────────────────────────────────────────

    public function getUuid(): UuidInterface              { return $this->uuid; }
    public function getNomeCompleto(): string             { return $this->nomeCompleto; }
    public function getUsername(): string                 { return $this->username; }
    public function getEmail(): string                    { return $this->email; }
    public function getSenhaHash(): string                { return $this->senhaHash; }
    public function getNivelAcesso(): string              { return $this->nivelAcesso; }
    public function isAtivo(): bool                       { return $this->ativo; }
    public function isEmailVerificado(): bool             { return $this->verificadoEmail; }
    public function getCriadoEm(): DateTimeImmutable      { return $this->criadoEm; }
    public function getAtualizadoEm(): ?DateTimeImmutable { return $this->atualizadoEm; }

    // ── Setters ───────────────────────────────────────────────────────────

    public function setNomeCompleto(string $v): void { $this->nomeCompleto = trim($v); }
    public function setUsername(string $v): void     { $this->username = strtolower(trim($v)); }
    public function setEmail(string $v): void        { self::validarEmail($v); $this->email = strtolower(trim($v)); }
}
Por que construtor privado?

Garante que um Usuario só pode ser criado via registrar() (novo) ou reconstituir() (do banco), nunca diretamente com new Usuario(...). Isso evita objetos em estado inválido.

Passo 3 — Repository

A interface define o contrato de persistência. O Repository concreto implementa as queries SQL — o Service nunca toca no banco diretamente.

Arquivo: src/Modules/Usuario/Repositories/UsuarioRepositoryInterface.php

php
<?php

namespace Src\Modules\Usuario\Repositories;

use Src\Kernel\Contracts\UserRepositoryInterface; // Contrato do Kernel — obrigatório para o Auth funcionar
use Src\Modules\Usuario\Entities\Usuario;

interface UsuarioRepositoryInterface extends UserRepositoryInterface
{
    // CRUD básico
    public function salvar(Usuario $usuario): void;
    public function deletar(string $uuid): void;
    public function buscarPorUuid(string $uuid): ?Usuario;
    public function buscarPorEmail(string $email): ?Usuario;
    public function listar(int $limite = 50, int $offset = 0): array;
    public function emailExiste(string $email, ?string $excluirUuid = null): bool;

    // Obrigatórios pelo UserRepositoryInterface do Kernel (necessários para o Auth funcionar)
    public function buscarPorUsername(string $username): ?object;
    public function marcarEmailComoVerificado(string $uuid, bool $verificado = true): void;
    public function salvarTokenRecuperacaoSenha(string $uuid, string $token): void;
    public function buscarPorTokenRecuperacaoSenha(string $token): ?object;
    public function limparTokenRecuperacaoSenha(string $uuid): void;
    public function salvarTokenVerificacaoEmail(string $uuid, string $token): void;
    public function buscarPorTokenVerificacaoEmail(string $token): ?object;
}

Arquivo: src/Modules/Usuario/Repositories/UsuarioRepository.php

php
<?php

namespace Src\Modules\Usuario\Repositories;

use PDO;
use DateTimeImmutable;
use Ramsey\Uuid\Uuid;
use Src\Modules\Usuario\Entities\Usuario;

class UsuarioRepository implements UsuarioRepositoryInterface
{
    public function __construct(private PDO $pdo) {} // PDO injetado pelo Container

    public function salvar(Usuario $usuario): void
    {
        $driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
        $uuid   = $usuario->getUuid()->toString();

        // Upsert: insere ou atualiza com uma única query (sem SELECT antes)
        if ($driver === 'pgsql') {
            $sql = "INSERT INTO usuarios
                        (uuid, nome_completo, username, email, senha_hash, nivel_acesso, ativo, verificado_email, criado_em)
                    VALUES
                        (:uuid, :nome, :username, :email, :hash, :nivel, :ativo, FALSE, :criado_em)
                    ON CONFLICT (uuid) DO UPDATE SET
                        nome_completo = EXCLUDED.nome_completo,
                        username      = EXCLUDED.username,
                        email         = EXCLUDED.email,
                        senha_hash    = EXCLUDED.senha_hash,
                        nivel_acesso  = EXCLUDED.nivel_acesso,
                        ativo         = EXCLUDED.ativo,
                        atualizado_em = NOW()";
        } else {
            $sql = "INSERT INTO usuarios
                        (uuid, nome_completo, username, email, senha_hash, nivel_acesso, ativo, verificado_email, criado_em)
                    VALUES
                        (:uuid, :nome, :username, :email, :hash, :nivel, :ativo, 0, :criado_em)
                    ON DUPLICATE KEY UPDATE
                        nome_completo = VALUES(nome_completo),
                        username      = VALUES(username),
                        email         = VALUES(email),
                        senha_hash    = VALUES(senha_hash),
                        nivel_acesso  = VALUES(nivel_acesso),
                        ativo         = VALUES(ativo),
                        atualizado_em = NOW()";
        }

        $this->pdo->prepare($sql)->execute([
            ':uuid'      => $uuid,
            ':nome'      => $usuario->getNomeCompleto(),
            ':username'  => $usuario->getUsername(),
            ':email'     => $usuario->getEmail(),
            ':hash'      => $usuario->getSenhaHash(),
            ':nivel'     => $usuario->getNivelAcesso(),
            ':ativo'     => $usuario->isAtivo() ? 1 : 0,
            ':criado_em' => $usuario->getCriadoEm()->format('Y-m-d H:i:s'),
        ]);
    }

    public function buscarPorUuid(string $uuid): ?Usuario
    {
        $stmt = $this->pdo->prepare('SELECT * FROM usuarios WHERE uuid = :uuid LIMIT 1');
        $stmt->execute([':uuid' => $uuid]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        return $row ? $this->mapear($row) : null;
    }

    public function buscarPorEmail(string $email): ?Usuario
    {
        $stmt = $this->pdo->prepare('SELECT * FROM usuarios WHERE email = :email LIMIT 1');
        $stmt->execute([':email' => strtolower($email)]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        return $row ? $this->mapear($row) : null;
    }

    public function listar(int $limite = 50, int $offset = 0): array
    {
        $stmt = $this->pdo->prepare('SELECT * FROM usuarios ORDER BY criado_em DESC LIMIT :l OFFSET :o');
        $stmt->bindValue(':l', $limite, PDO::PARAM_INT);
        $stmt->bindValue(':o', $offset, PDO::PARAM_INT);
        $stmt->execute();
        return array_map([$this, 'mapear'], $stmt->fetchAll(PDO::FETCH_ASSOC));
    }

    public function deletar(string $uuid): void
    {
        $this->pdo->prepare('DELETE FROM usuarios WHERE uuid = :uuid')->execute([':uuid' => $uuid]);
    }

    public function emailExiste(string $email, ?string $excluirUuid = null): bool
    {
        $sql    = 'SELECT COUNT(*) FROM usuarios WHERE email = :email';
        $params = [':email' => strtolower($email)];
        if ($excluirUuid !== null) {
            $sql .= ' AND uuid != :uuid'; // Ignora o próprio usuário ao atualizar
            $params[':uuid'] = $excluirUuid;
        }
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute($params);
        return (int) $stmt->fetchColumn() > 0; // fetchColumn() retorna o COUNT — não execute()
    }

    // Mapeia uma linha do banco para a Entity — centraliza o mapeamento em um único lugar
    private function mapear(array $row): Usuario
    {
        return Usuario::reconstituir(
            uuid:            Uuid::fromString($row['uuid']),
            nomeCompleto:    $row['nome_completo'],
            username:        $row['username'],
            email:           $row['email'],
            senhaHash:       $row['senha_hash'],
            nivelAcesso:     $row['nivel_acesso'],
            ativo:           (bool) $row['ativo'],
            verificadoEmail: (bool) ($row['verificado_email'] ?? false),
            criadoEm:        new DateTimeImmutable($row['criado_em']),
            atualizadoEm:    isset($row['atualizado_em']) ? new DateTimeImmutable($row['atualizado_em']) : null
        );
    }

    // ── Métodos obrigatórios pelo UserRepositoryInterface do Kernel ───────
    // O Auth usa estes métodos para autenticar, verificar e-mail e recuperar senha.

    /** Este módulo não usa username — retorna null para compatibilidade com o Kernel. */
    public function buscarPorUsername(string $username): ?object { return null; }

    public function marcarEmailComoVerificado(string $uuid, bool $verificado = true): void
    {
        $this->pdo->prepare('UPDATE usuarios SET ativo = :v WHERE uuid = :uuid')
            ->execute([':v' => $verificado ? 1 : 0, ':uuid' => $uuid]);
    }

    // Os métodos abaixo são stubs — implemente-os se quiser suporte a
    // recuperação de senha e verificação de e-mail neste módulo.
    public function salvarTokenRecuperacaoSenha(string $uuid, string $token): void {}
    public function buscarPorTokenRecuperacaoSenha(string $token): ?object { return null; }
    public function limparTokenRecuperacaoSenha(string $uuid): void {}
    public function salvarTokenVerificacaoEmail(string $uuid, string $token): void {}
    public function buscarPorTokenVerificacaoEmail(string $token): ?object { return null; }
}

Passo 4 — Service

O Service contém as regras de negócio. Recebe a interface do Repository via construtor — o Container injeta a implementação concreta automaticamente.

Arquivo: src/Modules/Usuario/Services/UsuarioService.php

php
<?php

namespace Src\Modules\Usuario\Services;

use DomainException;
use Src\Modules\Usuario\Entities\Usuario;
use Src\Modules\Usuario\Repositories\UsuarioRepositoryInterface;

class UsuarioService
{
    // Recebe a interface — nunca a implementação concreta (princípio da inversão de dependência)
    public function __construct(private UsuarioRepositoryInterface $repository) {}

    /**
     * Cria um novo usuário.
     * Recebe a Entity já construída — as validações ficam em Usuario::registrar().
     */
    public function criar(Usuario $usuario): void
    {
        if ($this->repository->emailExiste($usuario->getEmail())) {
            throw new DomainException('E-mail já cadastrado.', 409);
        }
        $this->repository->salvar($usuario);
    }

    public function atualizar(string $uuid, array $dados): Usuario
    {
        $usuario = $this->buscarPorUuid($uuid); // Lança 404 se não encontrar

        if (isset($dados['nome_completo'])) $usuario->setNomeCompleto($dados['nome_completo']);
        if (isset($dados['username']))      $usuario->setUsername($dados['username']);
        if (isset($dados['email'])) {
            if ($this->repository->emailExiste($dados['email'], $uuid)) {
                throw new DomainException('E-mail já cadastrado.', 409);
            }
            $usuario->setEmail($dados['email']);
        }
        if (isset($dados['senha']))        $usuario->alterarSenha($dados['senha']);
        if (isset($dados['nivel_acesso'])) $usuario->promoverPara($dados['nivel_acesso']);

        $this->repository->salvar($usuario);
        return $usuario;
    }

    public function buscarPorUuid(string $uuid): Usuario
    {
        return $this->repository->buscarPorUuid($uuid)
            ?? throw new DomainException('Usuário não encontrado.', 404);
    }

    public function buscarPorEmail(string $email): ?Usuario
    {
        return $this->repository->buscarPorEmail($email);
    }

    public function listar(int $pagina = 1, int $porPagina = 20): array
    {
        $offset = ($pagina - 1) * $porPagina;
        return $this->repository->listar($porPagina, $offset);
    }

    public function desativar(string $uuid): void
    {
        $usuario = $this->buscarPorUuid($uuid);
        $usuario->desativar();
        $this->repository->salvar($usuario);
    }

    public function deletar(string $uuid): void
    {
        $this->buscarPorUuid($uuid); // Garante que existe antes de deletar
        $this->repository->deletar($uuid);
    }
}

Passo 5 — Controller

O Controller recebe a requisição HTTP, delega ao Service e retorna a resposta. Não contém regras de negócio.

Arquivo: src/Modules/Usuario/Controllers/UsuarioController.php

php
<?php

namespace Src\Modules\Usuario\Controllers;

use DomainException;
use Src\Kernel\Http\Request\Request;
use Src\Kernel\Http\Response\Response;
use Src\Modules\Usuario\Entities\Usuario;
use Src\Modules\Usuario\Services\UsuarioService;

class UsuarioController
{
    public function __construct(private UsuarioService $service) {}

    /** POST /api/usuarios — Registrar novo usuário */
    public function criar(Request $request): Response
    {
        try {
            $b    = $request->body ?? [];
            $nome = trim((string) ($b['nome_completo'] ?? $b['nome'] ?? ''));
            $user = trim((string) ($b['username'] ?? ''));
            $mail = trim((string) ($b['email'] ?? ''));
            $pass = (string) ($b['senha'] ?? '');

            if ($nome === '' || $user === '' || $mail === '' || $pass === '') {
                return Response::json(['status' => 'error', 'message' => 'Campos obrigatórios: nome_completo, username, email, senha.'], 422);
            }

            // Cria a Entity (validações ficam em Usuario::registrar())
            $usuario = Usuario::registrar($nome, $user, $mail, $pass);
            $this->service->criar($usuario); // Service verifica duplicidade e persiste

            return Response::json(['status' => 'success', 'usuario' => $this->serial($usuario)], 201);
        } catch (DomainException $e) {
            return Response::json(['status' => 'error', 'message' => $e->getMessage()], $e->getCode() ?: 422);
        } catch (\Throwable $e) {
            return Response::json(['status' => 'error', 'message' => 'Erro ao criar usuário.'], 500);
        }
    }

    /** GET /api/usuarios — Listar usuários */
    public function listar(Request $request): Response
    {
        $pagina    = (int) ($request->query['pagina']    ?? 1);
        $porPagina = (int) ($request->query['por_pagina'] ?? 20);
        $usuarios  = $this->service->listar($pagina, $porPagina);
        return Response::json(['status' => 'success', 'usuarios' => array_map([$this, 'serial'], $usuarios)]);
    }

    /** GET /api/usuarios/{uuid} — Buscar por UUID */
    public function buscar(Request $request, string $uuid): Response
    {
        try {
            return Response::json(['status' => 'success', 'usuario' => $this->serial($this->service->buscarPorUuid($uuid))]);
        } catch (DomainException $e) {
            return Response::json(['status' => 'error', 'message' => $e->getMessage()], 404);
        }
    }

    /** PUT /api/usuarios/{uuid} — Atualizar usuário */
    public function atualizar(Request $request, string $uuid): Response
    {
        try {
            $usuario = $this->service->atualizar($uuid, $request->body ?? []);
            return Response::json(['status' => 'success', 'usuario' => $this->serial($usuario)]);
        } catch (DomainException $e) {
            return Response::json(['status' => 'error', 'message' => $e->getMessage()], $e->getCode() ?: 422);
        } catch (\Throwable $e) {
            return Response::json(['status' => 'error', 'message' => 'Erro ao atualizar usuário.'], 500);
        }
    }

    /** DELETE /api/usuarios/{uuid} — Deletar usuário */
    public function deletar(Request $request, string $uuid): Response
    {
        try {
            $this->service->deletar($uuid);
            return Response::json(['status' => 'success', 'message' => 'Usuário removido.']);
        } catch (DomainException $e) {
            return Response::json(['status' => 'error', 'message' => $e->getMessage()], $e->getCode() ?: 404);
        }
    }

    /** Serializa a Entity para array — nunca exponha o senhaHash na resposta */
    private function serial(\Src\Modules\Usuario\Entities\Usuario $u): array
    {
        return [
            'uuid'            => $u->getUuid()->toString(),
            'nome_completo'   => $u->getNomeCompleto(),
            'username'        => $u->getUsername(),
            'email'           => $u->getEmail(),
            'nivel_acesso'    => $u->getNivelAcesso(),
            'ativo'           => $u->isAtivo(),
            'verificado_email'=> $u->isEmailVerificado(),
            'criado_em'       => $u->getCriadoEm()->format('Y-m-d\TH:i:sP'),
        ];
    }
}

Passo 6 — Migration

Arquivo: src/Modules/Usuario/Database/Migrations/001_create_usuarios.php

php
<?php

return [
    'up' => function (PDO $pdo): void {
        $driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);

        if ($driver === 'pgsql') {
            $pdo->exec("
                CREATE TABLE IF NOT EXISTS usuarios (
                    uuid             UUID         NOT NULL PRIMARY KEY,
                    nome_completo    VARCHAR(255) NOT NULL,
                    username         VARCHAR(50)  NOT NULL UNIQUE,
                    email            VARCHAR(255) NOT NULL UNIQUE,
                    senha_hash       VARCHAR(255) NOT NULL,
                    nivel_acesso     VARCHAR(20)  NOT NULL DEFAULT 'usuario'
                        CHECK (nivel_acesso IN ('usuario','admin','moderador','admin_system')),
                    ativo            BOOLEAN      NOT NULL DEFAULT TRUE,
                    verificado_email BOOLEAN      NOT NULL DEFAULT FALSE,
                    criado_em        TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP,
                    atualizado_em    TIMESTAMP
                )
            ");
            $pdo->exec('CREATE INDEX IF NOT EXISTS idx_usuarios_email    ON usuarios (email)');
            $pdo->exec('CREATE INDEX IF NOT EXISTS idx_usuarios_username ON usuarios (username)');
        } else {
            $pdo->exec("
                CREATE TABLE IF NOT EXISTS usuarios (
                    uuid             CHAR(36)     NOT NULL PRIMARY KEY,
                    nome_completo    VARCHAR(255) NOT NULL,
                    username         VARCHAR(50)  NOT NULL UNIQUE,
                    email            VARCHAR(255) NOT NULL UNIQUE,
                    senha_hash       VARCHAR(255) NOT NULL,
                    nivel_acesso     VARCHAR(20)  NOT NULL DEFAULT 'usuario',
                    ativo            TINYINT(1)   NOT NULL DEFAULT 1,
                    verificado_email TINYINT(1)   NOT NULL DEFAULT 0,
                    criado_em        DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
                    atualizado_em    DATETIME,
                    INDEX idx_email    (email),
                    INDEX idx_username (username)
                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
            ");
        }
    },
    'down' => function (PDO $pdo): void {
        $pdo->exec('DROP TABLE IF EXISTS usuarios');
    },
];

Passo 7 — Seeder

Troque a senha em produção!

Defina ADMIN_EMAIL e ADMIN_PASSWORD no .env antes de rodar em produção.

Arquivo: src/Modules/Usuario/Database/Seeders/001_admin_user.php

php
<?php

return function (PDO $pdo): void {
    $email = $_ENV['ADMIN_EMAIL']    ?? '[email protected]';
    $senha = $_ENV['ADMIN_PASSWORD'] ?? 'Admin@123';
    $nome  = $_ENV['ADMIN_NAME']     ?? 'Administrador';

    // Não insere se o e-mail já existir
    $stmt = $pdo->prepare('SELECT 1 FROM usuarios WHERE email = :email LIMIT 1');
    $stmt->execute([':email' => $email]);
    if ($stmt->fetchColumn()) {
        echo "  ⊘ Admin já existe: {$email}\n";
        return;
    }

    $uuid = \Ramsey\Uuid\Uuid::uuid4()->toString();
    $hash = password_hash($senha, PASSWORD_ARGON2ID);

    $driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
    $ativo  = $driver === 'pgsql' ? 'TRUE' : '1';

    $username = $_ENV['ADMIN_USERNAME'] ?? 'admin';

    $pdo->prepare("
        INSERT INTO usuarios (uuid, nome_completo, username, email, senha_hash, nivel_acesso, ativo, verificado_email, criado_em)
        VALUES (:uuid, :nome, :username, :email, :hash, 'admin_system', {$ativo}, {$ativo}, NOW())
    ")->execute([':uuid' => $uuid, ':nome' => $nome, ':username' => $username, ':email' => $email, ':hash' => $hash]);

    echo "  ✔ Admin criado: {$email} / {$senha}\n";
    echo "  ⚠  TROQUE A SENHA EM PRODUÇÃO!\n";
};

Passo 8 — connection.php

Arquivo: src/Modules/Usuario/Database/connection.php

php
<?php
// 'core' usa DB_* do .env | 'modules' usa DB2_* | 'auto' detecta automaticamente
return 'core';

Passo 9 — Rotas

Arquivo: src/Modules/Usuario/Routes/web.php

php
<?php

use Src\Modules\Usuario\Controllers\UsuarioController;

/** @var \Src\Kernel\Contracts\RouterInterface $router */
$router->post('/api/usuarios',          [UsuarioController::class, 'criar']);    // Registrar
$router->get('/api/usuarios',           [UsuarioController::class, 'listar']);   // Listar (admin)
$router->get('/api/usuarios/{uuid}',    [UsuarioController::class, 'buscar']);   // Buscar por UUID
$router->put('/api/usuarios/{uuid}',    [UsuarioController::class, 'atualizar']); // Atualizar
$router->delete('/api/usuarios/{uuid}', [UsuarioController::class, 'deletar']);  // Deletar

Rotas disponíveis

MétodoRotaDescrição
POST/api/usuariosRegistrar novo usuário
GET/api/usuariosListar usuários
GET/api/usuarios/{uuid}Buscar por UUID
PUT/api/usuarios/{uuid}Atualizar dados
DELETE/api/usuarios/{uuid}Deletar usuário
Pronto!

Com todos os arquivos criados, rode as migrations e o seeder para inicializar o banco:

bash
php vupi migrate --seed

Depois teste o registro com:

bash
curl -X POST http://localhost/api/usuarios \
  -H "Content-Type: application/json" \
  -d '{"nome_completo":"João Silva","username":"joao.silva","email":"[email protected]","senha":"Senha@123"}'