Entity, Migrations and Seeders
In this tutorial we build a simple blog. First we create the database structure and some test data.
Create an Entity
We start with a basic Article table, beginning with the Entity. Every table must have a corresponding entity.
Run:
php windwalker g entity ArticleThis creates src/Entity/Article.php, which looks like this:
<?php
// ...
#[Table('articles', 'article')]
class Article implements EntityInterface
{
use EntityTrait;
#[EntitySetup]
public static function setup(EntityMetadata $metadata): void
{
//
}
}#[Table] on the class declares the table name. We will edit it later. Next, create the migration.
Create a Migration
Run:
php windwalker mig:create ArticleInitThe system asks a few questions. Choose the Article entity and create (select by number):
Entity name? [** IGNORE **]:
[0] ** IGNORE **
[1] App\Entity\Acme
[2] App\Entity\Article
> 2
Is an update or create new entity? [create]:
[0] create
[1] update
> 0
[CREATE] resources/migrations/2025101608170001_ArticleInit.phpA file named xxxxxxxxxxxxxx_ArticleInit.php is created under resources/migrations.
Open it and define the article table columns:
<?php
// ...
use App\Entity\Article;
use Windwalker\Core\Console\ConsoleApplication;
use Windwalker\Core\Migration\Migration;
use Windwalker\Database\Schema\Schema;
// ...
return new /** 2025101608170001_ArticleInit */ class extends AbstractMigration {
#[MigrateUp]
public function up(): void
{
$this->createTable(
Article::class,
function (Schema $schema) {
$schema->primary('id');
$schema->integer('category_id');
$schema->varchar('title');
$schema->varchar('image');
$schema->tinyint('state');
$schema->longtext('content');
$schema->datetime('created');
$schema->integer('created_by');
$schema->json('params');
$schema->addIndex('category_id');
$schema->addIndex('created_by');
}
);
}
#[MigrateDown]
public function down(): void
{
$this->dropTables(Article::class);
}
};We use the App\Entity\Article::class we just created as the table name and define some common columns. down() removes the table when the migration is rolled back.
Run the following command to migrate up and create the table:
php windwalker mig:go -fOn success it prints:
Migration start...
==================
20210620165937 ArticleInit UP... Success
Completed.You can check the migration status with:
> php windwalker mig:status
+--------+--- Migration Status ------------+
| Status | Version | Migration Name |
+--------+----------------+----------------+
| up | 20210619155333 | AcmeInit |
| up | 20210620165937 | ArticleInit |
+--------+----------------+----------------+Create Entity Properties
Since all database mapping is handled by entities, we need to set up the Entity first.
The table already exists, so we can build the entity properties from it:
php windwalker build:entity ArticleResult:
Handling: App\Entity\Article
Added columns:
- categoryId (category_id)
- title (title)
- image (image)
- content (content)
- created (created)
- createdBy (created_by)
- params (params)The system creates the matching properties from the table. Open the file:
<?php // src/Entity/Article.php
#[Table('articles', 'article')]
class Article implements EntityInterface
{
use EntityTrait;
#[Column('id'), PK, AutoIncrement]
public ?int $id = null;
#[Column('category_id')]
public int $categoryId = 0;
#[Column('title')]
public string $title = '';
#[Column('image')]
public string $image = '';
#[Column('state')]
public int $state = 0;
#[Column('content')]
public string $content = '';
#[Column('created')]
#[CastNullable(ServerTimeCast::class)]
public ?Chronos $created = null;
#[Column('created_by')]
public int $createdBy = 0;
#[Column('params')]
#[JsonObject]
public array $params = [];
}Every column has a matching property and default value, and some columns have a Cast to help convert types.
This is where you set up all possible type adjustments. For example, the params column stores JSON. With #[JsonObject] it is converted to an array when fetched and back to JSON when saved.
Custom Types
Most columns are handled automatically. If your entity uses special types or objects as values, adjust them by hand.
For the following examples we need a custom type. Run:
php windwalker g enum ItemState --case enabled --case disabledThis quickly creates an ItemState enum. Change enabled and disabled from strings to 1 and 0:
- enum ItemState: string implements EnumRichInterface
+ enum ItemState: int implements EnumRichInterface
{
use EnumRichTrait;
- case ENABLED = 'enabled';
+ case ENABLED = 1;
- case DISABLED = 'disabled';
+ case DISABLED = 0;
// ...Use it for the state property in Article instead of int:
// ...
#[Column('state')]
- public int $state = 0;
+ public ItemState $state;
// ...Now Article accepts the enum as a value.
Casting
Casts convert types when data is fetched from the database into the object, and when it is taken from the object and stored in the database.
For more details, see the ORM documentation.
Property Hooks
Once all casts are set up, you can add property hooks to the entity. Run the same command as before, but with -k:
php windwalker build:entity Article -kResult:
Handling: App\Entity\Article
Added property hooks:
- state (set)
- created (set)Currently only state and created need property hooks. Both hold special objects, so scalar values must be converted when assigned.
#[Column('state')]
public ItemState $state {
set(ItemState|int $value) => $this->state = ItemState::wrap($value);
}
// ...
#[Column('created')]
#[CastNullable(ServerTimeCast::class)]
public ?Chronos $created = null {
set(\DateTimeInterface|string|null $value) => $this->created = Chronos::tryWrap($value);
}If you have different needs, you can write get / set hooks manually. For example, to give state a default value:
#[Column('state')]
public ItemState $state {
set(ItemState|int $value) => $this->state = ItemState::wrap($value);
+ get => $this->state ??= ItemState::DISABLED;
}Create a Seeder
With the entity and its hooks ready, we can create seeders to fill in test data.
Run:
php windwalker seed:create articleThis creates:
[CREATE] resources/seeders/article.seeder.phpSeeders depend on execution order, so register it manually in resources/seeders/main.seeder.php:
<?php
return [
__DIR__ . '/acme.seeder.php',
+ __DIR__ . '/article.seeder.php',
];When there are more and more seeders, this list controls the execution order.
Now open article.seeder.php and fill in fake articles:
<?php
// ...
use App\Entity\Article;
use Windwalker\Core\Seed\AbstractSeeder;
use Windwalker\Core\Seed\SeedClear;
use Windwalker\Core\Seed\SeedImport;
use Windwalker\ORM\EntityMapper;
return new /** Article Seeder */ class extends AbstractSeeder {
#[SeedImport]
public function import(): void
{
$faker = $this->faker('en_US');
/** @var EntityMapper<Article> $mapper */
$mapper = $this->orm->mapper(Article::class);
foreach (range(1, 50) as $i) {
$item = $mapper->createEntity();
$item->title = $faker->sentence(2);
$item->image = 'https://placeholdit.com/600x400/dddddd/999999';
$item->content = $faker->paragraph(40);
$item->state = $faker->randomElement(ItemState::cases());
$item->createdBy = 1; // There is no User yet, use 1
$item->categoryId = 1; // There is no category yet, use 1
$item->created = $faker->dateTimeThisYear();
$item->params = [
'show_date' => true,
'show_author' => false,
];
$article = $mapper->createOne($item);
$this->printCounting();
}
}
#[SeedClear]
public function clear(): void
{
// Also add this
$this->truncate(Article::class);
}
};Because entity properties are public, IDEs can show what needs to be filled in and check the types.
->created = ... accepts several types because of the set hook generated earlier:
#[Column('created')]
#[CastNullable(ServerTimeCast::class)]
public ?Chronos $created = null {
set(\DateTimeInterface|string|null $value) => $this->created = Chronos::tryWrap($value);
}It converts whatever you pass into a Chronos object. You can modify any property or hook to add type checks or conversions. The goal is to make database access more convenient and type-safe.
Now run again:
php windwalker mig:reset -fsThis refreshes all migrations and seeders.