Di bagian sebelumnya kita sudah menguasai Blade untuk membangun tampilan yang dinamis. Sekarang saatnya turun ke lapisan database — fondasi dari hampir semua aplikasi web yang menyimpan data. Migration adalah cara Laravel mengelola struktur database menggunakan kode PHP yang bisa di-version control, dibagikan ke seluruh tim, dan di-rollback kapan saja jika terjadi kesalahan. Dengan migration, tidak ada lagi keharusan berbagi file SQL atau menjelaskan secara manual perubahan tabel ke rekan tim.

Panduan

Urutan Kerja Migration

Buat database kosong

Buat database sesuai nama di file .env.

#
Tulis struktur tabel

Definisikan kolom di method up().

#
Jalankan migrate

Laravel akan membuat tabel berdasarkan migration.

💡 Ini adalah bagian kelima dari seri "Belajar Laravel dari Nol". Di bagian ini kita fokus pada migration dan schema database. Pastikan konfigurasi database di file .env sudah benar dan database sudah dibuat sebelum menjalankan perintah migration.


Apa Itu Migration dan Mengapa Penting?

Bayangkan migration seperti sistem version control (Git) khusus untuk struktur database. Setiap perubahan schema — membuat tabel baru, menambah kolom, mengubah tipe data, menambah index — dicatat sebagai file migration terpisah yang bisa dijalankan secara berurutan. Tim yang bergabung ke project cukup menjalankan satu perintah dan database mereka langsung identik dengan yang ada di production, tanpa perlu import SQL manual.

"Migrations are like version control for your database, allowing your team to define and share the application's database schema definition."

— — Dokumentasi Resmi Laravel

Membuat File Migration

Setiap file migration dibuat menggunakan Artisan CLI. Laravel secara otomatis memberi nama file dengan timestamp — format YYYY_MM_DD_HHMMSS — yang memastikan migration selalu dijalankan dalam urutan yang benar, tidak peduli siapa yang membuatnya atau kapan.

Bash
# ── MEMBUAT FILE MIGRATION ─────────────────────────────

# Buat migration untuk tabel baru
php artisan make:migration create_posts_table
# File: database/migrations/2026_07_22_120000_create_posts_table.php

# Buat migration untuk modifikasi tabel yang sudah ada
php artisan make:migration add_thumbnail_to_posts_table
php artisan make:migration add_status_and_published_at_to_posts_table
php artisan make:migration rename_body_to_content_in_posts_table
php artisan make:migration drop_old_tags_table

# Buat Model + Migration sekaligus (paling efisien)
php artisan make:model Post -m
# Membuat: app/Models/Post.php
#          database/migrations/xxxx_create_posts_table.php

# Buat Model + Migration + Controller + Seeder sekaligus
php artisan make:model Post -mcs
php artisan make:model Post -mcrs  # + Resource Controller

# Lihat semua migration dan statusnya
php artisan migrate:status

Anatomi File Migration

Setiap file migration terdiri dari satu kelas yang mengextend Migration dengan dua method wajib: up() yang berisi perubahan yang ingin diterapkan, dan down() yang berisi cara membalikkan perubahan tersebut (rollback). Memahami struktur ini adalah kunci untuk menulis migration yang baik dan aman.

PHP
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

// Class migration — nama class harus unik
return new class extends Migration
{
    /**
     * Jalankan migration — membuat atau mengubah schema
     * Dipanggil saat: php artisan migrate
     */
    public function up(): void
    {
        Schema::create('posts', function (Blueprint $table) {
            // Definisi kolom ada di sini
            $table->id();                    // BIGINT UNSIGNED AUTO INCREMENT PRIMARY KEY
            $table->string('title');         // VARCHAR(255)
            $table->text('content');         // TEXT
            $table->timestamps();            // created_at + updated_at TIMESTAMP
        });
    }

    /**
     * Balikkan migration — menghapus atau memulihkan schema
     * Dipanggil saat: php artisan migrate:rollback
     */
    public function down(): void
    {
        Schema::dropIfExists('posts');
    }
};


Tipe Kolom yang Tersedia di Schema Builder

Schema Builder Laravel menyediakan method untuk semua tipe kolom yang umum digunakan di database relasional modern. Setiap method otomatis diterjemahkan ke tipe data yang tepat sesuai database yang digunakan (MySQL, PostgreSQL, SQLite, dll) — sehingga migration yang sama bisa berjalan di berbagai database tanpa perubahan.

PHP
<?php

Schema::create('contoh_semua_tipe', function (Blueprint $table) {

    // ── PRIMARY KEY ───────────────────────────────────
    $table->id();                      // BIGINT UNSIGNED AUTO_INCREMENT PK
    $table->uuid('id')->primary();     // UUID sebagai primary key
    $table->ulid('id')->primary();     // ULID sebagai primary key

    // ── STRING & TEKS ─────────────────────────────────
    $table->string('nama');            // VARCHAR(255)
    $table->string('kode', 10);        // VARCHAR(10) — panjang kustom
    $table->char('kode_negara', 2);    // CHAR(2) — fixed length
    $table->text('deskripsi');         // TEXT
    $table->mediumText('konten');      // MEDIUMTEXT (~16MB)
    $table->longText('artikel');       // LONGTEXT (~4GB)
    $table->tinyText('catatan');       // TINYTEXT (~255 bytes)

    // ── ANGKA ─────────────────────────────────────────
    $table->integer('views');          // INT
    $table->tinyInteger('rating');     // TINYINT (-128 sampai 127)
    $table->smallInteger('stok');      // SMALLINT
    $table->mediumInteger('populasi'); // MEDIUMINT
    $table->bigInteger('revenue');     // BIGINT

    // Versi unsigned (hanya positif, range lebih besar)
    $table->unsignedInteger('likes');
    $table->unsignedBigInteger('views');

    // Desimal
    $table->float('rating');           // FLOAT
    $table->double('koordinat');       // DOUBLE
    $table->decimal('harga', 10, 2);   // DECIMAL(10,2) — 10 digit, 2 desimal

    // ── BOOLEAN ───────────────────────────────────────
    $table->boolean('is_active');      // TINYINT(1)
    $table->boolean('is_featured')->default(false);

    // ── TANGGAL & WAKTU ────────────────────────────────
    $table->date('tanggal_lahir');     // DATE
    $table->time('jam_buka');          // TIME
    $table->dateTime('jadwal');        // DATETIME
    $table->timestamp('published_at'); // TIMESTAMP
    $table->year('tahun_masuk');       // YEAR

    // Shortcut untuk created_at + updated_at
    $table->timestamps();              // TIMESTAMP NULL x2
    $table->softDeletes();             // deleted_at TIMESTAMP NULL (soft delete)
    $table->timestampsTz();            // Dengan timezone

    // ── JSON ──────────────────────────────────────────
    $table->json('metadata');          // JSON (atau TEXT di SQLite)
    $table->jsonb('settings');         // JSONB (PostgreSQL only)

    // ── ENUM ──────────────────────────────────────────
    $table->enum('status', ['draft', 'published', 'archived']);
    $table->set('days', ['Mon', 'Tue', 'Wed', 'Thu', 'Fri']);

    // ── FILE / BINARY ─────────────────────────────────
    $table->binary('data');            // BLOB
    $table->string('thumbnail')->nullable(); // Path ke file

    // ── FOREIGN KEY (shorthand) ───────────────────────
    $table->foreignId('user_id');                    // BIGINT UNSIGNED
    $table->foreignId('category_id')->nullable();    // Boleh null
    $table->foreignUuid('profile_id');               // UUID foreign key
});


Modifier Kolom: nullable, default, after, dan Lainnya

Setiap definisi kolom bisa ditambahkan modifier — method yang di-chain setelah tipe kolom untuk mengubah perilaku kolom tersebut. Modifier memungkinkan kamu mengontrol apakah kolom boleh null, apa nilai defaultnya, di mana posisinya, apakah ada index, dan berbagai properti lainnya.

PHP
<?php

Schema::create('posts', function (Blueprint $table) {
    $table->id();

    // ── NULLABLE: Boleh berisi nilai NULL ─────────────
    $table->string('subtitle')->nullable();
    $table->text('excerpt')->nullable();
    $table->timestamp('published_at')->nullable();
    $table->string('thumbnail')->nullable();

    // ── DEFAULT: Nilai default jika tidak diisi ───────
    $table->boolean('is_featured')->default(false);
    $table->boolean('is_active')->default(true);
    $table->string('status')->default('draft');
    $table->integer('views')->default(0);
    $table->integer('order')->default(0);

    // ── UNSIGNED: Hanya nilai positif ─────────────────
    $table->integer('likes')->unsigned()->default(0);

    // ── UNIQUE: Nilai harus unik di tabel ─────────────
    $table->string('slug')->unique();
    $table->string('email')->unique();

    // ── INDEX: Mempercepat query pencarian ────────────
    $table->string('status')->index();       // Single index
    $table->index(['status', 'created_at']); // Composite index

    // ── AFTER: Posisi kolom (MySQL only) ──────────────
    $table->string('subtitle')->after('title')->nullable();
    $table->string('slug')->after('subtitle');

    // ── COMMENT: Komentar kolom di database ───────────
    $table->integer('views')->default(0)->comment('Jumlah kali dilihat');

    // ── GABUNGAN MODIFIER ─────────────────────────────
    $table->string('meta_title', 60)
        ->nullable()
        ->after('content')
        ->comment('SEO meta title, maks 60 karakter');

    $table->decimal('harga', 10, 2)
        ->unsigned()
        ->default(0)
        ->comment('Harga dalam Rupiah');

    $table->timestamps();
    $table->softDeletes(); // Tambah kolom deleted_at untuk soft delete
});


Foreign Key dan Relasi Antar Tabel

Foreign key adalah kolom yang menghubungkan satu tabel dengan tabel lain — fondasi dari database relasional. Laravel menyediakan dua cara mendefinisikan foreign key di migration: cara ringkas dengan constrained() dan cara eksplisit dengan foreign()->references()->on(). Keduanya menghasilkan constraint yang sama di database.

#
Cara Ringkas dengan foreignId()->constrained()

Cara paling umum dan direkomendasikan di Laravel modern. foreignId('user_id') membuat kolom BIGINT UNSIGNED, dan ->constrained() otomatis menghubungkannya ke kolom id di tabel users. Laravel cukup pintar untuk menebak nama tabel dari nama kolom foreign key.

#
Cara Eksplisit dengan foreign()->references()->on()

Untuk kasus yang lebih kompleks — nama tabel tidak standar, foreign key ke kolom selain id, atau butuh konfigurasi lebih detail — gunakan cara eksplisit yang memberikan kontrol penuh atas semua aspek constraint.

#
Cascade: Apa yang Terjadi saat Data Induk Dihapus

onDelete() dan onUpdate() menentukan apa yang terjadi pada record anak saat record induknya dihapus atau diupdate. Pilihan utama: cascade (ikut terhapus/terupdate), restrict (tolak operasi jika masih ada anak), set null (jadikan null), dan no action (tidak melakukan apapun).

PHP
<?php

// ── CARA RINGKAS (direkomendasikan) ───────────────────
Schema::create('posts', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->text('content');

    // foreignId + constrained = foreign key ke tabel users.id
    $table->foreignId('user_id')->constrained();
    // → ALTER TABLE posts ADD CONSTRAINT posts_user_id_foreign
    //   FOREIGN KEY (user_id) REFERENCES users(id)

    // Dengan cascade delete — hapus post jika user dihapus
    $table->foreignId('user_id')
        ->constrained()
        ->cascadeOnDelete();

    // Boleh null — post tanpa kategori
    $table->foreignId('category_id')
        ->nullable()
        ->constrained()
        ->nullOnDelete();  // Set null jika kategori dihapus

    $table->timestamps();
});

// ── CARA EKSPLISIT ─────────────────────────────────────
Schema::create('posts', function (Blueprint $table) {
    $table->id();
    $table->unsignedBigInteger('author_id'); // Kolom dulu
    $table->unsignedBigInteger('editor_id')->nullable();

    // Definisikan foreign key secara terpisah
    $table->foreign('author_id')
        ->references('id')
        ->on('users')
        ->onDelete('cascade')
        ->onUpdate('cascade');

    $table->foreign('editor_id')
        ->references('id')
        ->on('users')
        ->onDelete('set null');

    $table->timestamps();
});

// ── PILIHAN CASCADE ────────────────────────────────────
// ->cascadeOnDelete()   → ikut terhapus
// ->cascadeOnUpdate()   → ikut terupdate
// ->nullOnDelete()      → jadi NULL saat induk dihapus
// ->restrictOnDelete()  → tolak hapus jika masih ada anak
// ->noActionOnDelete()  → tidak melakukan apa-apa


Memodifikasi Tabel yang Sudah Ada

Dalam pengembangan nyata, kamu tidak hanya membuat tabel baru — kamu juga sering perlu memodifikasi tabel yang sudah ada. Menambah kolom baru, mengubah tipe data, menghapus kolom yang tidak diperlukan, atau menambah index adalah operasi yang sangat umum saat requirement berubah atau fitur baru ditambahkan.

PHP
<?php

// ── MENAMBAH KOLOM ─────────────────────────────────────
// Migration: add_thumbnail_to_posts_table
Schema::table('posts', function (Blueprint $table) {
    $table->string('thumbnail')->nullable()->after('content');
    $table->string('meta_title', 60)->nullable()->after('title');
    $table->text('meta_description')->nullable()->after('meta_title');
    $table->unsignedInteger('views')->default(0)->after('content');
});

// ── MENGUBAH KOLOM ─────────────────────────────────────
// Perlu install: composer require doctrine/dbal
Schema::table('posts', function (Blueprint $table) {
    // Ubah panjang varchar
    $table->string('title', 500)->change();

    // Ubah tipe data
    $table->text('excerpt')->nullable()->change();

    // Ubah default value
    $table->boolean('is_active')->default(true)->change();
});

// ── MENGGANTI NAMA KOLOM ───────────────────────────────
Schema::table('posts', function (Blueprint $table) {
    $table->renameColumn('body', 'content');
    $table->renameColumn('image', 'thumbnail');
});

// ── MENGHAPUS KOLOM ────────────────────────────────────
Schema::table('posts', function (Blueprint $table) {
    $table->dropColumn('views');
    $table->dropColumn(['meta_title', 'meta_description']); // Hapus beberapa sekaligus
});

// ── MENAMBAH / MENGHAPUS INDEX ─────────────────────────
Schema::table('posts', function (Blueprint $table) {
    $table->index('status');                     // Tambah index
    $table->index(['status', 'published_at']);   // Composite index
    $table->unique('slug');                      // Tambah unique index

    $table->dropIndex('posts_status_index');     // Hapus index
    $table->dropUnique('posts_slug_unique');     // Hapus unique index
    $table->dropForeign('posts_user_id_foreign'); // Hapus foreign key
});

// ── MENGGANTI NAMA TABEL ───────────────────────────────
Schema::rename('posts', 'articles');

// ── MENGHAPUS TABEL ────────────────────────────────────
Schema::drop('posts');
Schema::dropIfExists('posts'); // Lebih aman — tidak error jika tidak ada

⚠️ Saat memodifikasi kolom yang sudah ada menggunakan ->change(), pastikan kamu sudah menginstal package doctrine/dbal dengan perintah composer require doctrine/dbal. Tanpa package ini, Laravel tidak bisa mengubah kolom yang sudah ada di sebagian besar database driver dan akan melempar error.


Menjalankan dan Mengelola Migration

Menulis file migration hanyalah setengah dari pekerjaannya — kamu juga perlu mengetahui cara menjalankan, memeriksa status, dan membalikkan migration dengan benar. Artisan menyediakan serangkaian perintah migration yang akan kamu gunakan setiap hari dalam pengembangan.

Bash
# ── MENJALANKAN MIGRATION ──────────────────────────────

# Jalankan semua migration yang belum dijalankan
php artisan migrate

# Jalankan migration + seeder sekaligus
php artisan migrate --seed

# Preview SQL yang akan dijalankan (tanpa eksekusi)
php artisan migrate --pretend

# ── MELIHAT STATUS ─────────────────────────────────────
php artisan migrate:status
# Output:
#  Migration name                          Batch / Status
#  2014_10_12_000000_create_users_table    [1] Ran
#  2024_01_15_create_posts_table           [1] Ran
#  2024_03_20_add_thumbnail_to_posts_table [2] Ran
#  2026_07_22_add_meta_to_posts_table      Pending  ← belum dijalankan

# ── ROLLBACK ───────────────────────────────────────────
# Batalkan migration terakhir (satu batch)
php artisan migrate:rollback

# Rollback beberapa batch sekaligus
php artisan migrate:rollback --step=3

# ── RESET DAN FRESH ────────────────────────────────────
# Reset: rollback SEMUA migration (down() semua file)
php artisan migrate:reset

# Fresh: drop semua tabel lalu migrate ulang dari awal
# (LEBIH CEPAT dari reset karena langsung drop table)
php artisan migrate:fresh

# Fresh + jalankan seeder (paling sering dipakai di development)
php artisan migrate:fresh --seed

# Fresh dengan seeder spesifik
php artisan migrate:fresh --seeder=DatabaseSeeder

# ── PERINTAH UNTUK PRODUCTION ──────────────────────────
# Jalankan migration dengan konfirmasi (aman untuk production)
php artisan migrate --force

# Cek apakah ada migration yang belum dijalankan
php artisan migrate:status | grep Pending

⚠️ JANGAN pernah menjalankan php artisan migrate:fresh atau migrate:reset di server production — perintah ini akan menghapus SEMUA data yang ada. Untuk production, selalu gunakan php artisan migrate saja untuk menjalankan migration baru yang belum dijalankan. Gunakan migrate:fresh hanya di environment development atau testing.


Seeder: Mengisi Database dengan Data Awal

Migration mengelola struktur database — tapi setelah tabel dibuat, kamu juga perlu data untuk dikerjakan. Seeder adalah kelas PHP yang bertugas mengisi tabel dengan data awal — baik data yang memang harus selalu ada (seperti kategori default atau user admin pertama), maupun data dummy untuk keperluan development dan testing.

#
Membuat dan Menulis Seeder

Buat file seeder menggunakan Artisan, lalu isi method run() dengan logika untuk memasukkan data ke database. Seeder bisa menggunakan Eloquent model langsung atau DB::table() untuk performa yang lebih cepat saat memasukkan data dalam jumlah besar.

#
Factory: Generate Data Dummy yang Realistis

Factory adalah kelas yang mendefinisikan "resep" untuk membuat data palsu yang realistis menggunakan library Faker. Factory sangat berguna untuk mengisi database development dengan ratusan atau ribuan record dummy yang terlihat nyata — nama pengguna, judul artikel, harga produk, dan sebagainya.

#
DatabaseSeeder: Orchestrator Semua Seeder

DatabaseSeeder adalah file seeder utama yang memanggil semua seeder lainnya dalam urutan yang benar. Urutan sangat penting karena seeder yang membutuhkan data dari tabel lain harus dijalankan setelah tabel tersebut sudah terisi.

Bash
# Buat file seeder
php artisan make:seeder CategorySeeder
php artisan make:seeder PostSeeder
php artisan make:seeder UserSeeder

# Buat Factory
php artisan make:factory PostFactory --model=Post
php artisan make:factory UserFactory --model=User
PHP
<?php
// database/seeders/CategorySeeder.php

namespace Database\Seeders;

use App\Models\Category;
use Illuminate\Database\Seeder;

class CategorySeeder extends Seeder
{
    public function run(): void
    {
        // Data kategori yang selalu harus ada
        $categories = [
            ['name' => 'Laravel',     'slug' => 'laravel',     'color' => '#FF2D20'],
            ['name' => 'PHP',         'slug' => 'php',         'color' => '#777BB4'],
            ['name' => 'JavaScript',  'slug' => 'javascript',  'color' => '#F7DF1E'],
            ['name' => 'Python',      'slug' => 'python',      'color' => '#3776AB'],
            ['name' => 'Docker',      'slug' => 'docker',      'color' => '#2496ED'],
            ['name' => 'Database',    'slug' => 'database',    'color' => '#336791'],
            ['name' => 'DevOps',      'slug' => 'devops',      'color' => '#FF6B6B'],
            ['name' => 'Tutorial',    'slug' => 'tutorial',    'color' => '#4ECDC4'],
        ];

        foreach ($categories as $category) {
            Category::updateOrCreate(
                ['slug' => $category['slug']], // Cari berdasarkan slug
                $category                       // Update atau create dengan data ini
            );
        }

        $this->command->info('CategorySeeder: ' . count($categories) . ' kategori berhasil di-seed.');
    }
}
PHP
<?php
// database/factories/PostFactory.php

namespace Database\Factories;

use App\Models\Category;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;

class PostFactory extends Factory
{
    public function definition(): array
    {
        $title = fake()->sentence(rand(4, 8));

        return [
            'title'        => rtrim($title, '.'),
            'slug'         => Str::slug($title) . '-' . fake()->unique()->numberBetween(1, 9999),
            'excerpt'      => fake()->paragraph(2),
            'content'      => implode("\n\n", fake()->paragraphs(rand(5, 10))),
            'thumbnail'    => null,
            'status'       => fake()->randomElement(['draft', 'published', 'published', 'published']),
            'is_featured'  => fake()->boolean(20), // 20% chance featured
            'views'        => fake()->numberBetween(0, 10000),
            'user_id'      => User::factory(),     // Buat user baru jika tidak ada
            'category_id'  => Category::inRandomOrder()->first()?->id,
            'published_at' => fake()->optional(0.8)->dateTimeBetween('-1 year', 'now'),
            'created_at'   => fake()->dateTimeBetween('-2 years', 'now'),
        ];
    }

    // State: post yang sudah dipublikasikan
    public function published(): static
    {
        return $this->state(fn (array $attributes) => [
            'status'       => 'published',
            'published_at' => fake()->dateTimeBetween('-1 year', 'now'),
        ]);
    }

    // State: post yang masih draft
    public function draft(): static
    {
        return $this->state(fn (array $attributes) => [
            'status'       => 'draft',
            'published_at' => null,
        ]);
    }

    // State: post yang featured
    public function featured(): static
    {
        return $this->state(fn (array $attributes) => [
            'is_featured' => true,
        ]);
    }
}
PHP
<?php
// database/seeders/DatabaseSeeder.php

namespace Database\Seeders;

use App\Models\User;
use App\Models\Post;
use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        // 1. Seed kategori dulu (tidak bergantung tabel lain)
        $this->call(CategorySeeder::class);

        // 2. Buat user admin
        User::factory()->create([
            'name'  => 'Admin DevPath',
            'email' => 'admin@devpath.id',
        ]);

        // 3. Buat 10 user biasa menggunakan Factory
        User::factory(10)->create();

        // 4. Buat post menggunakan Factory
        // 50 post published
        Post::factory(50)->published()->create();

        // 10 post draft
        Post::factory(10)->draft()->create();

        // 5 post featured dan published
        Post::factory(5)->published()->featured()->create();

        $this->command->info('✅ Database berhasil di-seed!');
        $this->command->info('   Admin: admin@devpath.id / password');
    }
}


Praktik: Migration Lengkap untuk Aplikasi Blog

Mari satukan semua konsep migration yang sudah dipelajari dalam contoh nyata — membuat seluruh schema database untuk aplikasi blog yang lengkap dengan semua tabel, foreign key, index, dan relasi yang dibutuhkan.

PHP
<?php
// database/migrations/2026_07_22_000001_create_categories_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('categories', function (Blueprint $table) {
            $table->id();
            $table->string('name', 100);
            $table->string('slug', 100)->unique();
            $table->text('description')->nullable();
            $table->string('color', 7)->default('#6366F1'); // Hex color
            $table->string('icon')->nullable();
            $table->unsignedInteger('order')->default(0);
            $table->boolean('is_active')->default(true);
            $table->timestamps();

            // Index untuk pencarian dan sorting
            $table->index(['is_active', 'order']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('categories');
    }
};
PHP
<?php
// database/migrations/2026_07_22_000002_create_posts_table.php

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->id();

            // Relasi ke user (penulis)
            $table->foreignId('user_id')
                ->constrained()
                ->cascadeOnDelete();

            // Relasi ke kategori
            $table->foreignId('category_id')
                ->nullable()
                ->constrained()
                ->nullOnDelete();

            // Konten utama
            $table->string('title');
            $table->string('slug')->unique();
            $table->text('excerpt')->nullable();
            $table->longText('content');

            // Media
            $table->string('thumbnail')->nullable();

            // SEO
            $table->string('meta_title', 60)->nullable();
            $table->text('meta_description')->nullable();

            // Status dan publikasi
            $table->enum('status', ['draft', 'published', 'archived'])
                ->default('draft');
            $table->boolean('is_featured')->default(false);
            $table->unsignedBigInteger('views')->default(0);
            $table->timestamp('published_at')->nullable();

            $table->timestamps();
            $table->softDeletes(); // Untuk soft delete

            // Index untuk query yang sering dipakai
            $table->index(['status', 'published_at']);
            $table->index(['user_id', 'status']);
            $table->index('is_featured');
            $table->fullText(['title', 'content']); // Full-text search (MySQL)
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('posts');
    }
};
PHP
<?php
// database/migrations/2026_07_22_000003_create_tags_table.php
// database/migrations/2026_07_22_000004_create_post_tag_table.php
// (Tabel pivot untuk relasi many-to-many post <-> tag)

// TAGS TABLE
return new class extends Migration
{
    public function up(): void
    {
        Schema::create('tags', function (Blueprint $table) {
            $table->id();
            $table->string('name', 50)->unique();
            $table->string('slug', 50)->unique();
            $table->timestamps();
        });

        // PIVOT TABLE: post_tag (many-to-many)
        // Konvensi Laravel: nama tabel singular, urutan alfabetis
        Schema::create('post_tag', function (Blueprint $table) {
            $table->foreignId('post_id')
                ->constrained()
                ->cascadeOnDelete();

            $table->foreignId('tag_id')
                ->constrained()
                ->cascadeOnDelete();

            // Composite primary key — tidak boleh ada duplikat
            $table->primary(['post_id', 'tag_id']);
        });
    }

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

💡 Perhatikan urutan pembuatan tabel di contoh di atas: categories dan users dibuat lebih dulu sebelum posts, karena posts memiliki foreign key ke kedua tabel tersebut. Laravel akan melempar error jika kamu mencoba membuat foreign key ke tabel yang belum ada. Timestamp di nama file migration memastikan urutan eksekusi yang benar secara otomatis.


Langkah Selanjutnya

Luar biasa — kamu sudah menguasai Migration dan Database Schema di Laravel secara menyeluruh! Mulai dari membuat migration, memahami semua tipe kolom dan modifier yang tersedia, mendefinisikan foreign key dan relasi, memodifikasi tabel yang sudah ada, menjalankan dan me-rollback migration, hingga mengisi database dengan Seeder dan Factory. Di bagian keenam seri ini, kita akan belajar Eloquent ORM — cara Laravel berinteraksi dengan database menggunakan syntax yang sangat ekspresif dan powerful, jauh melampaui query SQL biasa.

🚀 Sebelum lanjut ke Bagian 6, pastikan kamu sudah berlatih: (1) membuat migration lengkap untuk minimal dua tabel yang saling berelasi dengan foreign key, (2) menjalankan php artisan migrate dan php artisan migrate:fresh --seed dengan sukses, (3) membuat Factory dengan beberapa state berbeda, dan (4) menulis Seeder yang menggunakan Factory untuk mengisi data dummy. Database yang terisi data dummy yang realistis akan membuat belajar Eloquent di bagian berikutnya jauh lebih menyenangkan.