PHP MVCDOCUMENTAÇÃO
v2.0.0

Migrations e Schema

Criar e alterar schema com migrations PHP: Blueprint, Column, índices, chaves estrangeiras e relações entre tabelas.

Formato do arquivo

Arquivos database/migrations/*.php são ordenados pelo nome e retornam uma instância anônima de Framework\Database\Migration. Implemente up() e down().

use Framework\Database\Migration;
use Framework\Database\Schema;
use Framework\Database\Blueprint;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('posts', function (Blueprint $table): void {
            $table->id();
            $table->string('title', 150);
            $table->text('body')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('posts');
    }
};

Schema e Blueprint

Use Schema e Blueprint para definir DDL em migrations. O guia completo de Blueprint abaixo cobre criação e alteração de tabelas, todos os tipos/modificadores, índices, FKs, relações 1:1/1:N/N:1/N:N, SQL gerado, execução e limitações da implementação.

Guia completo de Blueprint e definição de schema

Esta seção explica a API real de Framework\Database\Blueprint, Column e Schema, do primeiro arquivo de migration até relacionamentos e SQL gerado. O código entregue configura MySQL/InnoDB; não há suporte SQLite nesta conexão. Exemplos de tabelas como posts e profiles são modelos didáticos que você pode criar, não classes ou tabelas preexistentes.

Limite importante: Blueprint é um gerador pequeno de DDL. Ele cria definições de tabela, colunas, índices e chaves estrangeiras. Não é um ORM de relacionamentos, não descobre relações automaticamente e não valida todos os argumentos antes de produzir SQL.

1. Como Blueprint funciona dentro do framework

Há dois fluxos distintos. No fluxo de schema, a migration chama Schema; a facade cria um Blueprint, o callback registra objetos Column e índices, e o framework monta SQL DDL e o entrega a Database::execMigration(). No uso cotidiano, Models consultam e alteram as tabelas já criadas.

Migrations/arquivo.php → Migration::up()
                       → Schema::create() ou Schema::table()
                       → Blueprint + Column / índices / FKs
                       → SQL DDL → Database::execMigration() → MySQL/InnoDB

Aplicação em execução: Model ou Database → SQL de leitura/escrita → tabelas

Blueprint não executa queries de domínio nem insere registros. Use Models ou Database/PDO para dados; use este builder para a estrutura do banco. O Migrator da CLI carrega apenas arquivos .php em database/migrations/, ordenados pelo nome.

2. Criar uma tabela do zero: exemplo iniciante

Cenário e objetivo. Você está começando uma pequena aplicação de catálogo e precisa guardar produtos com nome, preço e datas de auditoria. Vamos criar uma migration reversível, entender cada arquivo e verificar o resultado. O nome e as colunas abaixo são exemplos; os métodos usados existem em Blueprint.php.

  1. Configure as variáveis do banco em .env (por exemplo DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME e DB_PASSWORD). A conexão ativa está em config/database.php; no código atual é MySQL.
  2. Na raiz do projeto, gere o esqueleto: php mvc make:migration CreateProductsTable. O comando cria um arquivo com prefixo de data/hora em database/migrations/. O stub inicial usa Schema::create(); revise e complete o conteúdo antes de executar.
  3. Edite o arquivo gerado para que tenha esta forma:
<?php

use Framework\Database\Blueprint;
use Framework\Database\Migration;
use Framework\Database\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('products', function (Blueprint $table): void {
            $table->id();
            $table->string('name', 150);
            $table->decimal('price', 10, 2);
            $table->boolean('active')->default(1);
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('products');
    }
};

O que foi feito. O arquivo retorna uma instância anônima de Migration, com up() para aplicar e down() para desfazer. Schema::create() recebe o nome literal da tabela e um callback com Blueprint. id() cria um INT UNSIGNED AUTO_INCREMENT PRIMARY KEY; string(), decimal() e boolean() registram colunas; timestamps() cria created_at e updated_at.

Resultado esperado. Ao chamar php mvc migrate, o Migrator carrega o arquivo, chama up() e registra a migration e seu batch na tabela migrations. Para esta tabela, Blueprint produz um CREATE TABLE IF NOT EXISTS InnoDB com charset utf8mb4 e collation utf8mb4_unicode_ci. O SQL exato depende das colunas declaradas; mais adiante há uma saída real de exemplo.

O comando migrate conecta e executa DDL de verdade. Faça o primeiro teste em um banco local/de desenvolvimento com backup adequado. CREATE TABLE IF NOT EXISTS não atualiza uma tabela que já existe: se a tabela estiver presente, o comando não converte a estrutura antiga para a nova.

3. Alterar uma tabela existente

Cenário e objetivo. A tabela products já existe e agora precisa de um SKU opcional. Para alteração usa-se Schema::table(); o callback ainda recebe um Blueprint, mas o framework gera uma sequência de ALTER TABLE, não um CREATE TABLE.

<?php

use Framework\Database\Blueprint;
use Framework\Database\Migration;
use Framework\Database\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::table('products', function (Blueprint $table): void {
            $table->string('sku', 64)->nullable();
        });
    }

    public function down(): void
    {
        Schema::table('products', function (Blueprint $table): void {
            $table->dropColumn('sku');
        });
    }
};

Como funciona. Schema::table() compila as operações e executa cada statement em sequência, imediatamente. No exemplo, up() adiciona sku VARCHAR(64) NULL; o rollback remove a coluna. A migration só é registrada pelo Migrator depois que up() termina. Em MySQL, DDL pode fazer commit implícito, então uma falha no meio pode deixar parte das alterações aplicada.

dropColumn(string|array $columns) aceita um nome ou uma lista e gera um ALTER TABLE ... DROP COLUMN por coluna. Para desfazer uma remoção, down() pode recriar a coluna com tipo e nulabilidade equivalentes, mas não recupera os valores apagados. Faça backup antes de migrations destrutivas.

O que o builder não oferece: não há métodos implementados para alterar tipo/nulabilidade de coluna existente, renomear coluna, remover índice, remover foreign key ou renomear tabela. Não use exemplos de outros frameworks como change(), renameColumn() ou dropForeign(); essas APIs não existem neste Blueprint.

4. Catálogo completo de tipos de coluna

Todos os métodos abaixo pertencem a Blueprint. Salvo os métodos de conveniência indicados depois, cada método de coluna retorna Framework\Database\Column, permitindo encadear os modificadores descritos na próxima seção. Os tamanhos/valores são colocados no SQL; Blueprint não verifica se cabem nos limites do tipo nem se precisão e escala são coerentes.

Números

Método e assinaturaSQL geradoQuando usar / observações
tinyInteger(string $name): ColumnTINYINTInteiro pequeno com sinal.
unsignedTinyInteger(string $name): ColumnTINYINT UNSIGNEDFaixa não negativa de TINYINT no MySQL.
smallInteger(string $name): ColumnSMALLINTInteiro pequeno/médio com sinal.
unsignedSmallInteger(string $name): ColumnSMALLINT UNSIGNEDContagens não negativas que cabem no tipo.
mediumInteger(string $name): ColumnMEDIUMINTInteiro MySQL de faixa intermediária.
unsignedMediumInteger(string $name): ColumnMEDIUMINT UNSIGNEDVersão sem sinal do MEDIUMINT.
integer(string $name): ColumnINTInteiro de uso geral com sinal.
unsignedInteger(string $name): ColumnINT UNSIGNEDInteiro não negativo; id() e foreignId() usam esta largura.
bigInteger(string $name): ColumnBIGINTInteiro de faixa maior com sinal.
unsignedBigInteger(string $name): ColumnBIGINT UNSIGNEDInteiro grande não negativo. Não é o tipo produzido por foreignId().
decimal(string $name, int $precision = 10, int $scale = 2): ColumnDECIMAL(p,s)Precisão fixa, apropriada para valores financeiros com escala conhecida; defina p/s conscientemente.
float(string $name, int $precision = 8, int $scale = 2): ColumnFLOAT(p,s)Medidas aproximadas; evite para dinheiro. O formato de precisão/escala depende da versão/configuração MySQL.
double(string $name, int $precision = 8, int $scale = 2): ColumnDOUBLE(p,s)Ponto flutuante aproximado de precisão maior que FLOAT; também não é decimal exato.

Texto

Método e assinaturaSQL geradoQuando usar / observações
char(string $name, int $length = 1): ColumnCHAR(n)Texto de tamanho fixo, como uma sigla curta.
string(string $name, int $length = 255): ColumnVARCHAR(n)Texto curto/variável, como nome, título ou e-mail.
text(string $name): ColumnTEXTTexto longo; capacidade é regida pelo tipo MySQL e charset.
mediumText(string $name): ColumnMEDIUMTEXTConteúdo mais extenso, conforme limites do MySQL.
longText(string $name): ColumnLONGTEXTConteúdo muito grande; não escolha sem necessidade.

Data e hora

Método e assinaturaSQL geradoQuando usar / observações
date(string $name): ColumnDATEData sem hora.
time(string $name): ColumnTIMEHorário/duração conforme semântica do MySQL.
year(string $name): ColumnYEARTipo YEAR específico do MySQL.
dateTime(string $name): ColumnDATETIMEData/hora; não adiciona fuso horário.
timestamp(string $name): ColumnTIMESTAMPTimestamp do MySQL, sujeito às regras e faixa da versão do servidor.

Booleanos e tipos especiais

Método e assinaturaSQL geradoQuando usar / observações
boolean(string $name): ColumnTINYINT(1)Convenção booleana MySQL; o builder não cria CHECK constraint nem converte/valida automaticamente o valor.
json(string $name): ColumnJSONRequer versão do MySQL com suporte ao tipo JSON.
enum(string $name, array $values): ColumnENUM('a','b',...)Lista fechada codificada no schema. Os itens são interpolados sem escaping; use apenas valores literais controlados.
uuid(string $name): ColumnCHAR(36)Armazena uma string UUID; não gera UUID, não a torna chave primária e não adiciona índice/unique.
id(string $name = 'id'): ColumnINT UNSIGNED AUTO_INCREMENT PRIMARY KEYConvenção de chave primária auto-incrementável; não é BIGINT nem UUID.
foreignId(string $name): ColumnINT UNSIGNED + constraint se aplicado como FKCria coluna unsigned e a marca para compilação de FK; encadeie constrained() antes de executar a migration.

Convenções adicionais: timestamps(): void cria created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP e updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP. Não retorna Column e não pode ser encadeado. softDeletes(): Column cria deleted_at DATETIME NULL e retorna essa Column. A coluna só participa da exclusão lógica do Model se o Model correspondente usar $softDelete = true; criar a coluna, por si só, não ativa esse comportamento. O Model base tem $timestamps = true por padrão; verifique também as colunas na tabela.

5. Column: nulabilidade, padrões e modificadores encadeáveis

Uma coluna normal retorna Column. Estes métodos alteram a definição guardada e retornam a própria instância (static), exceto os métodos internos de compilação. O SQL final é montado na ordem fixa: tipo, NULL/NOT NULL, DEFAULT, depois texto de rawExtra, independentemente da ordem em que os modificadores foram chamados.

MétodoAssinatura / retornoComportamento e uso
nullable()nullable(bool $value = true): staticSem chamada, coluna é NOT NULL. nullable() gera NULL; nullable(false) volta para NOT NULL. Não define valor padrão.
default()default(mixed $value, bool $raw = false): staticDefine o default. Com raw=false (padrão), strings são cercadas por aspas simples, mas não escapadas; números são anexados ao SQL. raw=true insere o texto como expressão SQL, usado internamente para CURRENT_TIMESTAMP. Use apenas valores estáticos e confiáveis.
rawExtra()rawExtra(string $sql): staticAnexa SQL literal ao fim da definição, por exemplo ON UPDATE CURRENT_TIMESTAMP. Não escapa nem valida. Uma chamada substitui o texto extra anterior.
constrained()constrained(?string $table = null, string $column = 'id'): staticGuarda a tabela/coluna referenciada. Sem tabela, tenta inferir removendo o sufixo _id do nome e adicionando s; a regra é simplista, portanto prefira indicar explicitamente a tabela e, se preciso, a coluna.
onDelete()onDelete(string $action): staticConfigura a ação de FK no DELETE do registro pai; converte a string para maiúsculas. Padrão interno: RESTRICT.
onUpdate()onUpdate(string $action): staticConfigura a ação de FK no UPDATE da chave referenciada; converte a string para maiúsculas. Padrão interno: RESTRICT.

Exemplo: $table->string('nickname', 80)->nullable()->default('guest'); gera uma coluna VARCHAR(80) NULL DEFAULT 'guest'. Um default e a nulabilidade são conceitos distintos: permitir NULL não cria um default, e definir um default não torna a coluna nullable.

SQL cru e escaping: identificadores (tabela, coluna e índice) e expressões DDL são interpolados sem quoting/validação geral. Strings comuns passadas a default() são colocadas entre aspas sem escape; enum(), rawExtra() e default(..., raw: true) também entram no SQL literalmente. Use somente nomes e valores constantes escritos no código da migration. Nunca passe input HTTP, conteúdo de banco ou dados externos a esses métodos.

6. Índices, unique e remoção de coluna

index(string|array $columns, ?string $name = null): void cria índice comum. unique(string|array $columns, ?string $name = null): void cria índice/constraint único. Com uma string, é um índice de uma coluna; com array, a ordem define um índice composto. Se não informar o nome, os nomes são idx_{tabela}_{colunas} e uniq_{tabela}_{colunas}. Os dois métodos retornam void: não encadeie modificadores após eles.

$table->string('email', 180);
$table->unique('email', 'uniq_customers_email');

$table->index(['status', 'created_at'], 'idx_orders_status_created');

unique() impede duplicidade conforme a regra do banco; index() não garante unicidade. Um índice composto em (status, created_at) não equivale automaticamente a índices independentes para qualquer ordem de busca. O builder não inspeciona dados existentes: adicionar unique a uma coluna com duplicatas pode fazer a migration falhar. Identificadores e nomes fornecidos são interpolados sem escape.

dropColumn(string|array $columns): void registra colunas a remover; é aplicado por Schema::table() via um ALTER TABLE ... DROP COLUMN para cada nome. toCreateSql() não usa essa lista, então chamar dropColumn() dentro de Schema::create() não remove nada.

7. Chaves estrangeiras: exatamente como configurar

foreignId($name) produz INT UNSIGNED e marca a coluna como foreign ID. O FK é compilado para essa marca, e constrained() informa o destino. Exemplo recomendado com a tabela explícita:

$table->foreignId('user_id')
      ->constrained('users')
      ->onDelete('cascade');

O SQL gerado contém CONSTRAINT fk_{tabela}_{coluna} FOREIGN KEY (...) REFERENCES users(id) ON DELETE CASCADE ON UPDATE RESTRICT. O nome da constraint é criado automaticamente; não há parâmetro para personalizá-lo. Sem chamadas a onDelete()/onUpdate(), ambas as ações ficam em RESTRICT. A implementação converte o texto recebido para maiúsculas, mas não valida nem limita as ações: use apenas ações aceitas pelo MySQL/InnoDB e deixe o banco rejeitar opções inválidas.

Obrigatório na prática: chame constrained() para cada coluna criada por foreignId(). A implementação emite a constraint sempre que encontra esse tipo, mesmo que uma tabela referenciada não tenha sido configurada; nesse caso pode produzir SQL inválido. Informe explicitamente a tabela, pois a inferência automática apenas remove _id e acrescenta s (user_id → users, mas nomes irregulares não são singularizados corretamente).

Tipos/chaves precisam ser compatíveis e o alvo deve existir quando o DDL for executado. Como id() é INT UNSIGNED, foreignId() combina com essa convenção. Apesar de haver unsignedBigInteger(), não há método separado para marcar um BIGINT como foreign ID; chamar constrained() sobre outra Column não basta para gerar a FK. Chaves estrangeiras compostas também não têm método próprio. Coloque migrations das tabelas-pai antes das tabelas-filhas (a execução é ordenada pelo nome do arquivo).

onDelete('cascade') apaga fisicamente os filhos quando o registro pai é apagado. SET NULL exige que a coluna FK seja nullable; caso contrário a ação não consegue gravar o resultado. Uma exclusão lógica do Model apenas atualiza deleted_at, portanto não aciona ON DELETE CASCADE. A tabela real api_tokens do projeto usa foreignId('user_id')->constrained('users')->onDelete('CASCADE') como exemplo implementado.

SQL efetivamente gerado por Blueprint

Para deixar o resultado concreto, este CREATE TABLE foi conferido executando em PHP os mesmos métodos de Blueprint (ID, FK para users, string, decimal, timestamps, soft delete, unique e índice composto). Os identificadores e valores são inseridos diretamente como código; a saída mostra também que o builder não envolve os nomes em backticks.

CREATE TABLE IF NOT EXISTS posts (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY NOT NULL,
    user_id INT UNSIGNED NOT NULL,
    title VARCHAR(150) NOT NULL,
    price DECIMAL(10,2) NOT NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    deleted_at DATETIME NULL,
    UNIQUE KEY uniq_posts_title (title),
    INDEX idx_posts_user_title (user_id, title),
    CONSTRAINT fk_posts_user_id FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE RESTRICT
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;

toAlterSql() também retorna statements em vez de uma única alteração; Schema::table() chama execMigration() para cada item, na ordem abaixo. A lista foi gerada com as operações indicadas:

ALTER TABLE posts ADD COLUMN published_at DATETIME NULL;
ALTER TABLE posts ADD COLUMN team_id INT UNSIGNED NOT NULL;
ALTER TABLE posts ADD CONSTRAINT fk_posts_team_id FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE posts DROP COLUMN old_code;
ALTER TABLE posts DROP COLUMN old_note;
ALTER TABLE posts ADD INDEX idx_posts_published_at (published_at);

Essa ordenação importa: colunas novas são adicionadas primeiro, uma FK de foreignId() vem logo após sua coluna, depois as colunas registradas para remoção e, por fim, índices. Cada statement é executado separadamente; uma falha pode deixar as operações anteriores aplicadas.

8. Modelagem das relações comuns

As relações abaixo são constraints no banco, não métodos de associação no Model. O código do Model confirma que não há hasOne(), hasMany(), belongsTo() ou belongsToMany(). Models podem fazer joins/queries explícitas; para relações automáticas ou carregamento eager, essa implementação não fornece essa API.

1:1 — usuário e perfil

Cenário e objetivo. Cada perfil pertence a um usuário e, no máximo, um perfil pode apontar para o mesmo usuário. A FK sozinha permitiria vários perfis; a restrição unique em user_id é o que limita a um.

<?php

use Framework\Database\Blueprint;
use Framework\Database\Migration;
use Framework\Database\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('profiles', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('user_id')->constrained('users')->onDelete('cascade');
            $table->string('display_name', 120)->nullable();
            $table->unique('user_id', 'uniq_profiles_user');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('profiles');
    }
};

O resultado garante que um perfil tenha um usuário existente e que um usuário tenha no máximo um perfil. Não obriga cada usuário a ter perfil. Para tabela existente, mova esse callback para up() de uma migration; não cole-o diretamente fora da migration.

1:N e N:1 — usuário e publicações

Cenário e objetivo. Um usuário pode criar várias publicações; cada publicação pertence a um usuário. A FK fica no lado “muitos”, em posts.user_id. Do ponto de vista do post, a mesma estrutura é N:1 (muitos posts para um user), sem uma segunda constraint.

<?php

use Framework\Database\Blueprint;
use Framework\Database\Migration;
use Framework\Database\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('posts', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('user_id')->constrained('users')->onDelete('cascade');
            $table->string('title', 150);
            $table->text('body')->nullable();
            $table->timestamps();
            $table->index('user_id', 'idx_posts_user');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('posts');
    }
};

Ao terminar up(), cada linha de posts exige um user_id existente. Não existe lista automática $user->posts; faça a consulta de forma explícita, por exemplo com o wrapper Database:

$posts = \Framework\Database\Database::getInstance()
    ->query('SELECT posts.id, posts.title, users.name AS author_name
             FROM posts
             INNER JOIN users ON users.id = posts.user_id
             WHERE posts.user_id = :user_id')
    ->bind(':user_id', $userId)
    ->fetchAll();

O valor variável é vinculado como parâmetro; não concatene $userId na SQL. Para a API fluente do Model e seus joins explícitos, consulte Query Builder e Database/PDO.

N:N — usuários e papéis por tabela pivô

Cenário e objetivo. Um usuário pode ter vários papéis e cada papel pode pertencer a vários usuários. O framework não cria pivô nem métodos N:N automaticamente: declare uma tabela intermediária com duas FKs. A chave unique composta evita repetir o mesmo par.

<?php

use Framework\Database\Blueprint;
use Framework\Database\Migration;
use Framework\Database\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('roles', function (Blueprint $table): void {
            $table->id();
            $table->string('name', 60);
            $table->unique('name', 'uniq_roles_name');
        });

        Schema::create('role_user', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('user_id')->constrained('users')->onDelete('cascade');
            $table->foreignId('role_id')->constrained('roles')->onDelete('cascade');
            $table->unique(['user_id', 'role_id'], 'uniq_role_user_pair');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('role_user');
        Schema::dropIfExists('roles');
    }
};

Crie as tabelas-pai antes da pivô. No down(), remova primeiro role_user, depois roles; a tabela users pertence a outra migration e não deve ser apagada por esta. Se o par precisar ter atributos adicionais (por exemplo assigned_at), coloque-os na pivô.

9. Exemplo integrado: catálogo, pedidos e itens

Cenário e objetivo. Um pequeno sistema de vendas terá produtos, pedidos feitos por usuários já existentes e itens associados a cada pedido. Isso cobre 1:N (users → orders e orders → order_items) e, conceitualmente, N:N entre pedidos e produtos, representada pela tabela order_items. Uma única migration mostra ordem de criação e reversão.

<?php

use Framework\Database\Blueprint;
use Framework\Database\Migration;
use Framework\Database\Schema;

return new class extends Migration {
    public function up(): void
    {
        // A tabela products não depende de outra tabela deste exemplo.
        Schema::create('products', function (Blueprint $table): void {
            $table->id();
            $table->string('sku', 64);
            $table->string('name', 150);
            $table->decimal('price', 10, 2);
            $table->boolean('active')->default(1);
            $table->timestamps();
            $table->unique('sku', 'uniq_products_sku');
        });

        // users é criada pela migration existente do projeto.
        Schema::create('orders', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('user_id')->constrained('users')->onDelete('restrict');
            $table->string('status', 30)->default('pending');
            $table->timestamps();
            $table->index('user_id', 'idx_orders_user');
        });

        // Tabela de associação: cada linha liga um pedido a um produto.
        Schema::create('order_items', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('order_id')->constrained('orders')->onDelete('cascade');
            $table->foreignId('product_id')->constrained('products')->onDelete('restrict');
            $table->unsignedInteger('quantity')->default(1);
            $table->decimal('unit_price', 10, 2);
            $table->unique(['order_id', 'product_id'], 'uniq_order_product');
        });
    }

    public function down(): void
    {
        // Desfaz primeiro as dependências e só então a tabela-pai do exemplo.
        Schema::dropIfExists('order_items');
        Schema::dropIfExists('orders');
        Schema::dropIfExists('products');
    }
};

Como ler o resultado. Um usuário pode ter vários pedidos por orders.user_id. Cada pedido pode ter várias linhas em order_items; cada linha aponta para um produto. O unique composto permite apenas uma linha por par pedido/produto. Se seu domínio permite linhas separadas para o mesmo produto (variações, lote, preço ou desconto), altere a chave unique para incluir a variação ou retire a unique conforme as regras do domínio. unit_price preserva o preço usado naquele pedido; consultar apenas o preço atual de products não substituiria esse dado histórico.

Resultado esperado. Se todas as operações forem aceitas pelo MySQL, serão criadas três tabelas InnoDB com as colunas, defaults, índices e constraints declaradas. A migration assume que users já existe. O rollback remove as tabelas na ordem inversa das dependências. Nenhum Model Order, Product ou relação ORM é criado automaticamente; esses arquivos e consultas são responsabilidade da aplicação.

10. Executar, verificar e testar uma migration

  1. Gere/edite o arquivo em database/migrations/; confira namespace/imports, up(), down(), nomes de tabela e dependências.
  2. Confirme que o banco de desenvolvimento está configurado em .env e acessível.
  3. Execute php mvc migrate. O Migrator processa arquivos .php em ordem alfabética do prefixo timestamp, pula nomes já registrados e registra o batch somente depois de up() completar.
  4. Verifique a estrutura no cliente MySQL, por exemplo SHOW CREATE TABLE order_items;, e confira índices/FKs no schema do banco. Esse comando é SQL para o cliente do banco, não um subcomando MVC.
  5. Para validar o rollback em ambiente descartável, use php mvc migrate:rollback e depois reaplique com php mvc migrate. --step=N indica quantos batches desfazer, não quantos arquivos. Faça isso apenas quando o impacto nos dados for aceitável.

O framework não fornece nesta classe um validador estático/preview de DDL, dry-run do Blueprint ou método de teste de migration isolada. Pode inspecionar a string internamente com Blueprint::toCreateSql() / toAlterSql(), mas esses métodos são auxiliares públicos voltados ao Schema e não substituem execução em MySQL compatível. Erros de PDO são lançados; identifique a primeira instrução que falhou e confirme o schema real antes de repetir.

MySQL não garante rollback transacional de DDL. Se uma migration criar uma tabela e falhar na próxima operação, o que já foi executado pode continuar no banco, enquanto o nome da migration ainda não foi gravado na tabela migrations. Corrija o estado parcial com cuidado antes de executar novamente. migrate --fresh apaga e recria o banco inteiro; não use como atalho em dados que precisam ser preservados.

11. O que evitar: erros comuns e correção

EvitePor que falha / riscoFaça assim
foreignId('user_id') sem constrained()O compilador ainda tenta montar FK, mas a tabela de destino não foi definida; pode gerar SQL inválido.Use foreignId('user_id')->constrained('users') explicitamente.
integer('user_id')->constrained('users')constrained() existe em Column, mas o compilador só emite FK para coluna marcada por foreignId().Use foreignId() (INT UNSIGNED) e mantenha o tipo compatível com a chave referenciada.
Confiar sempre na inferência de tabela de constrained()A regra remove _id e acrescenta s; é uma pluralização simples, não trata nomes irregulares.Escreva constrained('users') ou constrained('categories').
$table->string('email')->unique()Column não possui método unique().Declare a coluna e depois use $table->unique('email') no Blueprint.
$table->index('x')->nullable() ou $table->timestamps()->nullable()index() e timestamps() retornam void.Configure a coluna antes; declare o índice à parte. timestamps() é chamada isolada.
Usar Schema::create() esperando alterar tabela existenteGera CREATE TABLE IF NOT EXISTS; não altera uma tabela que já existe.Crie uma nova migration e use Schema::table().
Concatenar entrada do usuário em nome/default/enum/raw SQLBlueprint interpolará identificadores, valores ou trechos crus no DDL; não há prepared bindings nem escaping geral.Use apenas identificadores e valores constantes no código de migration. Para dados de runtime, use Database::query()->bind().
Usar float()/double() para dinheiroPonto flutuante é aproximado.Use decimal('price', 10, 2) e defina a escala de negócio.
Usar SET NULL em FK NOT NULLO banco não consegue aplicar a ação referencial se a coluna não aceita NULL.Encadeie nullable() na coluna FK e valide que a regra do domínio permite ausência.
Excluir migration já aplicada sem planoO banco guarda seu nome/batch; apagar arquivo pode deixar o histórico inconsistente e impede executar o down() da cópia local.Adicione nova migration corretiva e mantenha os arquivos versionados.
Esperar hasMany(), auto-pivot, cascade em soft delete ou alteração de colunaEssas facilidades não são implementadas por este Blueprint/Model.Declare FKs/índices manualmente, faça queries/join explicitamente e planeje as migrations compatíveis com as operações disponíveis.

12. Referência técnica resumida e limites reais

Schema

AssinaturaO que fazRetorno / limite
Schema::create(string $table, callable $callback): voidCria Blueprint, chama callback e executa toCreateSql().Executa já; SQL contém CREATE TABLE IF NOT EXISTS. Identificador é interpolado.
Schema::table(string $table, callable $callback): voidCria Blueprint de alteração e executa cada statement retornado por toAlterSql().Execução sequencial e imediata; não há transação criada por Schema.
Schema::dropIfExists(string $table): voidExecuta DROP TABLE IF EXISTS.Use para down idempotente de tabela, com atenção aos dados.
Schema::drop(string $table): voidExecuta DROP TABLE.Falha se a tabela não existir; identificador interpolado.

Métodos auxiliares públicos de Blueprint

MétodoRetornoUso / observação
__construct(string $table, bool $isAlter = false)—Normalmente criado por Schema. O parâmetro $isAlter não é armazenado nem consultado no código atual; o tipo de operação é determinado por toCreateSql() ou toAlterSql(). Não dependa desse booleano para escolher comportamento.
toCreateSql(): stringString SQLCompilador chamado por Schema::create(). É público, mas comentado no código como interno do Schema. Não executa por si só.
toAlterSql(): array (docblock: string[])Lista de SQLsCompilador chamado por Schema::table(). Gera ADD COLUMN/FK, DROP COLUMN e ADD INDEX em ordem de categoria; não executa por si só.

Métodos públicos de Column usados internamente

MétodoRetornoObservação
__construct(string $name, string $type)—Chamado pelo Blueprint; a própria classe orienta não instanciar diretamente.
toSql(): stringDefinição SQL da colunaAuxiliar interno, não executa e não escapa identificadores/valores.
toForeignKeySql(string $table): stringDefinição SQL da constraintAuxiliar interno chamado quando o Blueprint encontra a marca de foreignId.

Para a API de Models e a ausência de relações ORM, consulte Models; para transações e statements parametrizados, Database e PDO; para queries/join, Query Builder; para criar/executar migrations, CLI. O ponto de partida das implementações é framework/Database/Schema.php, Blueprint.php, Column.php, Migration.php, Database.php e cli/Migrator.php.

Executar e reverter

php mvc make:migration create_posts_table
php mvc migrate
php mvc migrate:rollback
php mvc migrate:rollback --step=2

O Migrator mantém a tabela migrations, registra nome e batch, ignora migrations já executadas e desfaz batches pela ordem inversa usando down().

--fresh destrói o banco configurado. Ele executa DROP DATABASE e cria novamente; o comando pede confirmação e tem proteção de ambiente, mas use somente sabendo que todos os dados daquele banco serão perdidos. DDL MySQL pode fazer commit implícito: falhas parciais podem exigir reparo manual.

Progressão · criar e alterar schema

Uma migration é uma unidade versionada de DDL. Arquivos PHP retornam um objeto Migration anônimo; o Migrator ordena pelo nome do arquivo e registra o batch. O exemplo abaixo segue a estrutura real de users, sem depender de APIs externas ao Schema/Blueprint.

return new class extends Migration {
    public function up(): void
    {
        Schema::create('posts', function (Blueprint $table): void {
            $table->id();
            $table->string('title', 150);
            $table->text('body')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('posts');
    }
};

`Schema::create()` recebe nome e callback; cada método de coluna devolve Column para encadear nullable/default/constrained/onDelete/onUpdate. Para uma alteração, use `Schema::table()` e faça o `down()` remover ou restaurar o que `up()` mudou. O rollback executa migrations do batch em ordem inversa.

Executar e escolher o nível de reversão

ComandoAçãoRisco/uso
php mvc migrateAplica migrations pendentesFluxo normal após criar/ajustar a migration.
php mvc migrate:rollbackDesfaz o último batchExecuta down(); confirme impacto nos dados.
php mvc migrate:rollback --step=2Desfaz os dois batches mais recentesA opção step conta batches, não arquivos individuais.
php mvc migrate --freshDrop/recria o banco e reaplica migrationsDestrói dados; confirmação não substitui backup.
MySQL pode fazer commit implícito de DDL. Se uma migration falhar no meio, confira schema e tabela migrations antes de tentar de novo; um `down()` deve ser pensado para reverter o que `up()` realmente criou.

Veja também a relação com Models e comandos CLI.

Chaves estrangeiras e SQL gerado

foreignId($name) cria coluna e a marca para geração de FK; encadeie constrained($table) para informar a tabela referenciada antes de executar a migration. Sem constrained(), o gerador chega a SQL com tabela referenciada ausente, portanto não use foreignId isoladamente.

$table->foreignId('user_id')
      ->constrained('users')
      ->onDelete('cascade');

Schema executa DDL imediatamente no MySQL. Os nomes de tabela/coluna e expressões raw são interpolados no SQL gerado; mantenha-os como literais do código. default($value, raw: true) e rawExtra() aceitam SQL literal e não devem receber input externo. Os valores de enum também são interpolados sem escaping automático.

A referência inclui Database/PDO e a classe Migration base. O Migrator executa migrations PHP em ordem de nome; migrations SQL não são carregadas pelo comando atual.

migrate --fresh recria o database inteiro

php mvc migrate --fresh não limpa apenas as tabelas de migrations: o Migrator executa DROP e CREATE do database configurado. O comando pede que você digite o nome do database para confirmar; --yes suprime essa pergunta. Em produção, também é necessário --force. Faça e verifique um backup antes de qualquer execução; --force apenas autoriza o comando e não protege os dados.

# Interativo; leia a confirmação e digite o nome do database
php mvc migrate --fresh

# Não interativo (continua destrutivo)
php mvc migrate --fresh --yes