Skip to main content
Version: v8.x

Database

Modules use Laravel's standard Eloquent ORM and migration system. This page covers models, migrations, the registry migration rules (which apply if you plan to publish your addon), seeders, and factories.

Models

Models live under Modules/{Module}/app/Models/, with the namespace Modules\{Module}\Models. The fastest way to scaffold one is:

php artisan module:make-model Report Sample

After it's generated, open the model and set its $table property to the underlying table name. The table name must use your author prefix — see the warning below.

namespace Modules\Sample\Models;

use App\Contracts\Model;

class Report extends Model
{
protected $table = 'acme_reports';

protected $fillable = [
'name',
'pirep_id',
];
}

phpVMS provides App\Contracts\Model as a convenience base class — extend it instead of Illuminate\Database\Eloquent\Model to pick up phpVMS's shared traits and conventions.

See the Laravel Eloquent docs for full details on working with models.

Table names must be prefixed

Every table your addon creates must start with your author prefix — the first segment of your registry name. For an addon registered as acme/reports, every table must match ^acme_*.

Unprefixed (or wrongly prefixed) table names will collide with phpVMS core or other addons, and the addon registry migration linter will reject your submission.

Read Table name convention before writing your first migration.

Generating models with extras

You can scaffold a model alongside its migration, factory, seeder, and controller in one command:

# Model + migration
php artisan module:make-model Report Sample --migration

# Model + migration + factory
php artisan module:make-model Report Sample --migration --factory

# Everything: model, migration, factory, seeder, controller, request, resource, policy
php artisan module:make-model Report Sample --all

# Set the fillable array up front
php artisan module:make-model Report Sample --fillable="name,pirep_id,status"
FlagGenerates
--migration / -mA matching migration in database/migrations/
--factory / -fA factory in database/factories/
--seed / -sA seeder in database/seeders/
--controller / -cA controller in app/Http/Controllers/
--request / -rForm request classes
--resource / -RAn API resource class
--policy / -pA policy class
--all / -aAll of the above
--fillable=Sets the model's $fillable array

Relationships

If your table has a column called pirep_id, you can add a relationship to the core Pirep model:

namespace Modules\Sample\Models;

use App\Contracts\Model;
use App\Models\Pirep;

class Report extends Model
{
protected $table = 'acme_reports';

public function pirep()
{
return $this->belongsTo(Pirep::class, 'pirep_id');
}
}

Now you can access the parent PIREP without writing any queries:

$record = Report::with(['pirep'])->find(1);
echo $record->pirep->dpt_airport_id;

The Pirep model itself has relationships to airports, users, etc., so you can chain further:

echo $record->pirep->dpt_airport->name;

The right relationships make life easier. Read the Laravel relationships docs for the full set of patterns.

Scopes, casts, and observers

Standard Laravel patterns work as you'd expect:

# Reusable query scope
php artisan module:make-scope PublishedScope Sample

# Custom attribute cast
php artisan module:make-cast TitleCast Sample

# Lifecycle observer
php artisan module:make-observer ReportObserver Sample

Register an observer in your module's service provider:

// Modules/Sample/app/Providers/SampleServiceProvider.php

use Modules\Sample\Models\Report;
use Modules\Sample\Observers\ReportObserver;

public function boot(): void
{
parent::boot();

Report::observe(ReportObserver::class);
}

Inspecting a model

php artisan module:show-model Sample

Lists every model in the module along with its attributes, casts, and relationships — useful when joining a project you didn't write.


Migrations

phpVMS uses Laravel migrations to version your database schema. Generate a new migration with:

php artisan module:make-migration create_acme_reports_table Sample

This creates a file under Modules/{Module}/database/migrations/. When an operator visits /update, pending migrations from all enabled modules are run automatically — installs and updates require no manual SQL.

The core migrations under app/Database/migrations/ are a good reference for field types, indexes, and foreign keys.

Migration syntax

phpVMS uses Laravel's modern anonymous-class migration style:

<?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('acme_reports', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->foreignId('pirep_id')->constrained();
$table->timestamps();
});
}

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

Adding a column later? Generate another migration:

php artisan module:make-migration add_published_at_to_acme_reports_table Sample
return new class extends Migration
{
public function up(): void
{
Schema::table('acme_reports', function (Blueprint $table) {
$table->timestamp('published_at')->nullable();
});
}

public function down(): void
{
Schema::table('acme_reports', function (Blueprint $table) {
$table->dropColumn('published_at');
});
}
};
No raw SQL

Do not use DB::statement or DB::unprepared. Beyond being fragile across database engines, raw SQL is forbidden by the addon registry's migration linter — see Migration rules below.

Always add a new migration

Always add a new migration when you change a table schema after release. The migration runner records what's been applied, so previously-run files are skipped — editing an old migration after operators have run it has no effect on their database.

Running migrations

CommandEffect
module:migrate SampleRun pending migrations for one module.
module:migrateRun pending migrations for all modules.
module:migrate Sample --pretendShow the SQL without running it.
module:migrate Sample --seedRun migrations, then run the module's seeders.
module:migrate Sample --database=pgsqlRun against a specific database connection.
module:migrate-rollback SampleRoll back the last batch.
module:migrate-rollback Sample --subpath="2024_01_15_120000_create_acme_reports_table.php"Roll back a specific migration.
module:migrate-refresh Sample [--seed]Roll back everything, then re-run.
module:migrate-reset SampleRoll back everything (no re-run).
module:migrate-fresh SampleDrop tables and re-run. Warning — drops all tables on the connection, including ones from other modules.
module:publish-migration SampleCopy module migrations into the host app's database/migrations/ (one-time export).

Table name convention

Table names must be prefixed with your author namespace — the first segment of your addon registry name. For an addon registered as acme/reports, the author namespace is acme, and every table must match ^acme_*. Prefix pivot tables too.

// Good — matches the author namespace `acme`
Schema::create('acme_reports', ...);
Schema::create('acme_reports_runs', ...);

// Bad — no prefix, will collide with core or another addon
Schema::create('reports', ...);

// Bad — prefix doesn't match the author namespace
Schema::create('reports_acme', ...);

You can share tables across your own addons. If you publish both acme/reports and acme/inventory, both addons may legitimately read and write acme_* tables.

Not prefixing your tables (or using a prefix that doesn't match your namespace) will:

  • Conflict with future phpVMS core tables, breaking upgrades.
  • Conflict with other addons that share the same generic name.
  • Be rejected by the registry migration linter if you publish your addon.
Picking a namespace before you publish

If you haven't picked a registry name yet, choose one now and use its first segment as your prefix. Changing the prefix later means a destructive migration. The convention is to use your GitHub username or organisation as the author segment — see Publishing.


Migration rules

If you plan to publish your addon to the phpVMS addon registry, every migration under database/migrations/ is statically analysed at PR time using an allow-list. The author namespace is the first segment of your registry name — for acme/reports the namespace is acme, and your tables must match ^acme_*.

Rules at a glance

RuleAllowedForbidden
Migration classanonymous class extending Migrationclasses that don't extend Migration
Schema::create / table / drop / dropIfExists / rename table targetstables matching ^{author}_*core tables, other authors' tables, dynamic table names
DB::table() targetstables matching ^{author}_*core tables, other authors' tables, dynamic table names
DB::raw()always
DB::statement, DB::unpreparedalways forbidden
Foreign key referent (->on('users'))any table
foreignId(...)->constrained() (implicit referent)any table
eval, include, include_once, require, require_oncealways forbidden

Why allow-list, not deny-list

A deny-list of "core tables" would need updating every time phpVMS adds a table. The allow-list catches all core tables (none start with {author}_) and all other authors' tables automatically. It also lets you share tables across your own addons — acme/reports may legitimately read acme_inventory_* tables.

Examples that pass

// Schema operation on author-prefixed table
Schema::create('acme_reports_runs', function (Blueprint $t) {
$t->id();
$t->timestamps();
});

// Cross-addon, same author
Schema::table('acme_inventory_items', function (Blueprint $t) {
$t->boolean('reported')->default(false);
});

// Foreign key referent on a core table
Schema::create('acme_reports_runs', function (Blueprint $t) {
$t->id();
$t->foreignId('user_id')->constrained(); // implicit ->on('users')
$t->foreign('aircraft_id')->references('id')->on('aircraft'); // explicit
});

// DB::raw expression for a default value
Schema::create('acme_reports_runs', function (Blueprint $t) {
$t->timestamp('created_at')->default(DB::raw('CURRENT_TIMESTAMP'));
});

// Rename within author prefix
Schema::rename('acme_reports_old', 'acme_reports_new');

Examples that fail

// FAIL: target is a core table
Schema::create('users', function (Blueprint $t) {});

// FAIL: another author's prefix
Schema::create('beta_forms_responses', function (Blueprint $t) {});

// FAIL: dynamic table name
$name = 'foo';
Schema::create($name, function (Blueprint $t) {});

// FAIL: rename touches a core table
Schema::rename('users', 'acme_reports_users');

// FAIL: arbitrary SQL execution
DB::statement('TRUNCATE other_users');
DB::unprepared('UPDATE accounts SET ...');

// FAIL: eval / include / require
eval('echo 1;');
include 'helpers.php';
require_once 'config.php';

// FAIL: DB::table on a core table
DB::table('users')->update(['banned' => true]);

Common gotchas

  • Schema::create(...) table name must be a string literal. Patterns like $name = 'acme_x'; Schema::create($name, ...) are rejected — the lint can't prove what $name resolves to.
  • DB::table($var) is rejected for the same reason.
  • Forbidden imports/requires. Helpers belong in your service provider or a regular class file, not in migrations.
  • Drop migrations also need the prefix. Schema::drop('users') fails just as Schema::create('users') does. Drop only what you own.
  • down() is linted with the same rules as up().

If you find a real-world legitimate pattern the lint rejects, open an issue — the rules are tunable.


Seeding initial data

phpVMS supports two ways of seeding data: phpVMS's addData() migration helper for setup data shipped with a release, and Laravel's standard seeder classes for everything else.

Inside a migration (phpVMS-specific)

For rows that must exist for the addon to work — config defaults, default categories, lookup tables — use phpVMS's $this->addData() helper inside up(). Extending App\Contracts\Migration (instead of Laravel's base Migration) makes it available:

<?php

use App\Contracts\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::create('acme_reports_settings', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('group');
$table->text('value')->nullable();
$table->string('type');
$table->text('description')->nullable();
$table->integer('order')->default(0);
$table->timestamps();
});

$settings = [
[
'order' => 1,
'name' => 'Start Date',
'group' => 'general',
'value' => '',
'type' => 'date',
'description' => 'The date your VA started',
],
// …more entries
];

$this->addData('acme_reports_settings', $settings);
}

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

addData() runs once when the migration is applied — perfect for setup rows the addon depends on.

Standard Laravel seeders

For development data, demo content, or anything an operator should choose to load, use Laravel seeders. Generate one with:

php artisan module:make-seed AcmeReportsSeeder Sample
# → Modules/Sample/database/seeders/AcmeReportsSeeder.php
# → namespace Modules\Sample\Database\Seeders
namespace Modules\Sample\Database\Seeders;

use Illuminate\Database\Seeder;
use Modules\Sample\Models\Report;

class AcmeReportsSeeder extends Seeder
{
public function run(): void
{
Report::factory()->count(20)->create();
}
}

Each module ships with a master seeder named {Module}DatabaseSeeder.php that calls the others:

namespace Modules\Sample\Database\Seeders;

use Illuminate\Database\Seeder;

class SampleDatabaseSeeder extends Seeder
{
public function run(): void
{
$this->call([
AcmeReportsSeeder::class,
]);
}
}

Run seeders:

# Run the module's master seeder
php artisan module:seed Sample

# Run a specific seeder class
php artisan module:seed Sample --class=AcmeReportsSeeder

# Run all modules' master seeders
php artisan module:seed

# Migrate + seed in one step
php artisan module:migrate Sample --seed

Hooking into the main db:seed

If you want the host app's php artisan db:seed to also run your module's data, add it to database/seeders/DatabaseSeeder.php in the host app:

public function run(): void
{
$this->call([
\Modules\Sample\Database\Seeders\SampleDatabaseSeeder::class,
]);
}

Factories

Factories let you generate fake records for tests and seeders. Generate one with module:make-model Report Sample --factory, or stand-alone:

php artisan module:make-factory ReportFactory Sample
# → Modules/Sample/database/factories/ReportFactory.php
# → namespace Modules\Sample\Database\Factories
namespace Modules\Sample\Database\Factories;

use App\Models\Pirep;
use Illuminate\Database\Eloquent\Factories\Factory;

class ReportFactory extends Factory
{
public function definition(): array
{
return [
'name' => $this->faker->sentence(),
'pirep_id' => Pirep::factory(),
];
}

public function published(): self
{
return $this->state(['published_at' => now()]);
}
}
// Default 20 records
Report::factory()->count(20)->create();

// State helper
Report::factory()->published()->create();

// Relationships
Report::factory()
->for(Pirep::factory()->create())
->count(5)
->create();