Skip to main content
Laravel Modular automatically discovers and loads migrations from each module’s database/migrations/ directory, keeping your database schema organized alongside your module code.

Creating Migrations

Create migrations within a module using the --module flag:
php artisan make:migration create_posts_table --module=blog
This creates a migration file in:
app-modules/blog/database/migrations/2024_01_15_100000_create_posts_table.php
Migrations are automatically timestamped to ensure correct execution order.

Migration Structure

Module migrations are identical to standard Laravel migrations:
app-modules/blog/database/migrations/2024_01_15_100000_create_posts_table.php
<?php

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

return new class extends Migration
{
    public function up()
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->string('slug')->unique();
            $table->text('excerpt')->nullable();
            $table->longText('content');
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->timestamp('published_at')->nullable();
            $table->timestamps();
        });
    }

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

Running Migrations

Module migrations are automatically discovered and run with the standard migrate command:
php artisan migrate
This runs all pending migrations from:
  • Your application’s database/migrations/ directory
  • All module database/migrations/ directories
Migrations are executed in chronological order based on their timestamps, regardless of which module they belong to.

Migration Discovery

Migrations are automatically discovered from the database/migrations/ directory of each module:
app-modules/
├── blog/
│   └── database/
│       └── migrations/
│           ├── 2024_01_15_100000_create_posts_table.php
│           └── 2024_01_15_110000_create_categories_table.php
└── shop/
    └── database/
        └── migrations/
            ├── 2024_01_15_120000_create_products_table.php
            └── 2024_01_15_130000_create_orders_table.php
All these migrations are loaded automatically.

Common Migration Patterns

Creating Tables

php artisan make:migration create_posts_table --module=blog

Adding Columns

php artisan make:migration add_featured_to_posts_table --module=blog

Foreign Keys

Schema::create('comments', function (Blueprint $table) {
    $table->id();
    $table->foreignId('post_id')
          ->constrained()
          ->cascadeOnDelete();
    $table->foreignId('user_id')
          ->constrained()
          ->cascadeOnDelete();
    $table->text('content');
    $table->timestamps();
});
When creating foreign keys to tables in other modules, ensure the referenced table’s migration runs first by adjusting timestamps if needed.

Pivot Tables

For many-to-many relationships:
php artisan make:migration create_post_tag_table --module=blog
Schema::create('post_tag', function (Blueprint $table) {
    $table->foreignId('post_id')->constrained()->cascadeOnDelete();
    $table->foreignId('tag_id')->constrained()->cascadeOnDelete();
    $table->primary(['post_id', 'tag_id']);
    $table->timestamps();
});

Migration Commands

All standard Laravel migration commands work with module migrations:

Run Migrations

php artisan migrate

Rollback Migrations

php artisan migrate:rollback

Refresh Database

php artisan migrate:fresh

Migration Status

View which migrations have run:
php artisan migrate:status
This shows migrations from all modules with their status.

Generating Migrations with Models

Create a model and migration together:
php artisan make:model Post --module=blog --migration
Or use the shorthand:
php artisan make:model Post --module=blog -m
Create a model with migration, factory, and seeder:
php artisan make:model Post --module=blog -mfs

Cross-Module Relationships

When creating relationships between modules:
1

Create the base table first

Ensure the table being referenced exists before creating foreign keys:
// In users table (app migration)
Schema::create('users', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    // ...
});
2

Reference it in module migrations

// In blog module
Schema::create('posts', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')
          ->constrained('users')
          ->cascadeOnDelete();
    // ...
});
3

Verify execution order

Check that timestamps ensure correct execution order:
php artisan migrate:status

Migration Path Discovery

Laravel Modular automatically registers migration paths for all modules. Behind the scenes, it:
  1. Scans the app-modules/ directory
  2. Finds all database/migrations/ subdirectories
  3. Registers them with Laravel’s migrator
  4. Runs them in chronological order
This happens through the MigratorPlugin:
// From MigratorPlugin
public function handle(Collection $data): void
{
    $data->each(fn(string $path) => $this->migrator->path($path));
}

Testing Migrations

Test your migrations in your module tests:
app-modules/blog/tests/MigrationTest.php
<?php

namespace Modules\Blog\Tests;

use Tests\TestCase;
use Illuminate\Support\Facades\Schema;

class MigrationTest extends TestCase
{
    public function test_posts_table_has_expected_columns()
    {
        $this->assertTrue(Schema::hasTable('posts'));
        $this->assertTrue(Schema::hasColumn('posts', 'id'));
        $this->assertTrue(Schema::hasColumn('posts', 'title'));
        $this->assertTrue(Schema::hasColumn('posts', 'slug'));
        $this->assertTrue(Schema::hasColumn('posts', 'content'));
        $this->assertTrue(Schema::hasColumn('posts', 'published_at'));
    }
    
    public function test_posts_table_has_proper_indexes()
    {
        $indexes = Schema::getConnection()
            ->getDoctrineSchemaManager()
            ->listTableIndexes('posts');
            
        $this->assertArrayHasKey('posts_slug_unique', $indexes);
    }
}

Using RefreshDatabase

<?php

namespace Modules\Blog\Tests;

use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Modules\Blog\Models\Post;

class PostTest extends TestCase
{
    use RefreshDatabase;
    
    public function test_can_create_post()
    {
        $post = Post::factory()->create();
        
        $this->assertDatabaseHas('posts', [
            'id' => $post->id,
            'title' => $post->title,
        ]);
    }
}

Best Practices

Migration filenames should clearly describe what they do:
# Good
php artisan make:migration create_posts_table --module=blog
php artisan make:migration add_featured_to_posts_table --module=blog

# Bad
php artisan make:migration update_posts --module=blog
Make migrations reversible:
public function down()
{
    Schema::dropIfExists('posts');
}
Maintain referential integrity:
$table->foreignId('user_id')
      ->constrained()
      ->cascadeOnDelete();
Add indexes for frequently queried columns:
$table->string('slug')->unique();
$table->index(['user_id', 'published_at']);
Create separate migrations for different changes:
# One migration per logical change
php artisan make:migration create_posts_table --module=blog
php artisan make:migration create_categories_table --module=blog

Troubleshooting

Migrations Not Found

If migrations aren’t being discovered:
# Clear the module cache
php artisan modules:clear

# Check migration status
php artisan migrate:status

Migration Already Exists

If you see “migration already exists” errors, check for duplicate files across modules and the main app.

Foreign Key Constraint Errors

Ensure referenced tables exist before creating foreign keys. Adjust migration timestamps if needed:
// Rename file to adjust timestamp
// From: 2024_01_15_100000_create_comments_table.php
// To:   2024_01_15_110000_create_comments_table.php

Next Steps

Module Factories

Create test data with model factories

Module Components

Generate models and other components

Build docs developers (and LLMs) love