Models are active-record style classes built around declared properties.
For document-backed models with a similar declared-property style, see Mongo.
use Myxa\Database\Model\HasTimestamps;
use Myxa\Database\Model\Model;
final class User extends Model
{
use HasTimestamps;
protected string $table = 'users';
protected ?int $id = null;
protected string $email = '';
protected string $status = '';
}Model fields must be declared as real properties on the class.
final class User extends Model
{
protected string $table = 'users';
protected ?int $id = null;
protected string $email = '';
protected string $status = '';
}This affects both mass assignment and direct writes:
fill([...])accepts only declared propertiessetAttribute()and$model->property = ...accept only declared properties- unknown attributes throw an exception
#[Internal]properties are excluded from model field handling
These properties control how the model behaves:
$table: required table name$primaryKey: primary key column, defaults toid$connection: optional connection alias
Example with a custom primary key:
final class ExternalUser extends Model
{
protected string $table = 'external_users';
protected string $primaryKey = 'uuid';
protected ?string $uuid = null;
protected string $email = '';
}$user = User::create([
'email' => 'john@example.com',
'status' => 'active',
]);
$found = User::find(1);
$required = User::findOrFail(1);
$users = User::all();
$user->status = 'inactive';
$user->save();
$user->delete();Useful query helpers:
$users = User::query()
->where('status', '=', 'active')
->orderBy('id', 'DESC')
->limit(10)
->get();
$first = User::query()->where('status', '=', 'active')->first();
$exists = User::query()->where('status', '=', 'active')->exists();For large result sets, use cursor() to stream one model at a time:
foreach (User::query()->where('status', '=', 'active')->cursor() as $user) {
// $user is one User instance.
}Use chunk() when batch processing is more useful than one-by-one streaming:
User::query()->orderBy('id')->chunk(100, function (array $users, int $page): void {
foreach ($users as $user) {
// Process up to 100 User instances at a time.
}
});Factories are intentionally lightweight. The framework provides the base Factory, a small fake data generator, and a HasFactory trait. Your app defines the concrete factory class.
For the complete factory workflow, including raw(), make(), create(), state(), and the full fake data helper list, see Factory.
use Myxa\Database\Factory\Factory;
use Myxa\Database\Model\HasFactory;
final class User extends Model
{
use HasFactory;
protected string $table = 'users';
protected ?int $id = null;
protected string $email = '';
protected string $status = '';
protected static function newFactory(): Factory
{
return UserFactory::new();
}
}
final class UserFactory extends Factory
{
protected function model(): string
{
return User::class;
}
protected function definition(): array
{
return [
'email' => $this->faker()->unique()->email(),
'status' => $this->faker()->choice(['draft', 'active']),
];
}
}Usage:
$user = User::factory()->make();
$persisted = User::factory()->create();
$users = User::factory()
->count(3)
->create();
$admin = User::factory()
->state(['status' => 'admin'])
->create([
'email' => 'admin@example.com',
]);Available fake data helpers include:
$this->faker()->string(16)$this->faker()->alpha(12)$this->faker()->digits(6)$this->faker()->number(1, 100)$this->faker()->decimal(10, 99, 2)$this->faker()->boolean()$this->faker()->choice(['draft', 'active'])$this->faker()->word()$this->faker()->words(3)$this->faker()->sentence()$this->faker()->paragraph()$this->faker()->slug()$this->faker()->email()$this->faker()->unique()->email()
Guarded, Hidden, and Internal Attributes
use Myxa\Database\Attributes\Guarded;
use Myxa\Database\Attributes\Hidden;
use Myxa\Database\Attributes\Internal;
final class SecureUser extends Model
{
protected string $table = 'users';
protected ?int $id = null;
protected string $email = '';
protected string $status = '';
#[Guarded]
#[Hidden]
protected ?string $password_hash = null;
#[Internal]
protected string $helperLabel = 'draft';
}#[Guarded]: skipped byfill([...]), but trusted code can still set it directly#[Hidden]: omitted fromtoArray()andtoJson()#[Internal]: not treated as a persisted model field at all
Models support property-level casts through the #[Cast(...)] attribute.
The built-in casts currently support these types:
CastType::DateTimeCastType::DateTimeImmutableCastType::Json
use DateTimeImmutable;
use Myxa\Database\Attributes\Cast;
use Myxa\Database\Model\CastType;
final class User extends Model
{
protected string $table = 'users';
protected string $email = '';
protected string $status = '';
#[Cast(CastType::DateTimeImmutable, format: DATE_ATOM)]
protected ?DateTimeImmutable $created_at = null;
#[Cast(CastType::DateTimeImmutable, format: DATE_ATOM)]
protected ?DateTimeImmutable $updated_at = null;
}Behavior:
- hydrated string values are cast into
DateTimeorDateTimeImmutable - JSON strings are decoded during hydration when using
CastType::Json - serialized output converts datetime values back to strings
- JSON-cast attributes stay decoded in
toArray()/toJson() - SQL persistence stores JSON-cast attributes as JSON strings
- the cast format controls datetime parsing and serialization
use DateTimeImmutable;
use Myxa\Database\Attributes\Cast;
use Myxa\Database\Model\CastType;
final class Event extends Model
{
protected string $table = 'events';
#[Cast(CastType::DateTimeImmutable, format: DATE_ATOM)]
protected ?DateTimeImmutable $published_at = null;
#[Cast(CastType::Json)]
protected ?array $payload = null;
}There is no public extra() API on models.
The model is strict during normal writes:
fill([...])rejects unknown attributessetAttribute()rejects unknown attributes
But hydrated rows may still contain additional storage columns. Those values are available through getAttribute() and are included in serialization unless hidden.
$user = ExternalUser::hydrate([
'uuid' => 'abc-1',
'email' => 'external@example.com',
'computed_label' => 'External',
]);
$user->getAttribute('computed_label'); // 'External'
$user->toArray()['computed_label']; // 'External'Relationships are declared as methods returning hasOne(), hasMany(), or belongsTo().
use Myxa\Database\Model\Model;
use Myxa\Database\Model\ModelQuery;
final class User extends Model
{
protected string $table = 'users';
protected ?int $id = null;
protected string $email = '';
protected string $status = '';
public function profile(): ModelQuery
{
return $this->hasOne(Profile::class);
}
public function posts(): ModelQuery
{
return $this->hasMany(Post::class);
}
}
final class Post extends Model
{
protected string $table = 'posts';
protected ?int $id = null;
protected ?int $user_id = null;
protected string $title = '';
public function user(): ModelQuery
{
return $this->belongsTo(User::class);
}
}Default keys are inferred from model names, but you can pass explicit key names into hasOne(), hasMany(), and belongsTo().
Relation methods return relation queries:
$profile = $user->profile()->first();
$posts = $user->posts()->orderBy('id')->get();
$owner = $post->user()->first();Use with() on the model query to preload relations, including nested paths:
$users = User::query()
->with('profile', 'posts.comments')
->orderBy('id')
->get();Loaded relations can be checked or accessed with:
$user->relationLoaded('profile');
$user->getRelation('profile');Eager-loaded relations are included automatically in toArray() and toJson().
toArray() returns model attributes plus any loaded relations:
$payload = $user->toArray();Example output:
[
'id' => 1,
'email' => 'john@example.com',
'status' => 'active',
'created_at' => '2026-04-01T10:00:00+00:00',
'updated_at' => '2026-04-01T10:05:00+00:00',
]toJson() uses the same serializable payload:
$json = $user->toJson();The model also implements JsonSerializable, so json_encode($user) uses the same output.
Cloning a model creates a new unsaved copy:
$user = User::findOrFail(1);
$copy = clone $user;
$copy->email = 'copy@example.com';
$copy->save();When cloned:
- the model becomes non-persisted
- the primary key is cleared
- loaded relations are cleared
Use #[Hook(...)] on model methods to run code around persistence:
use Myxa\Database\Attributes\Hook;
use Myxa\Database\Model\HookEvent;
final class User extends Model
{
#[Hook(HookEvent::BeforeSave)]
protected function normalizeEmail(): void
{
$this->email = strtolower(trim($this->email));
}
#[Hook(HookEvent::AfterSave)]
protected function rememberAuditEntry(): void
{
// custom post-save logic
}
}Available hook events:
HookEvent::BeforeSaveHookEvent::AfterSaveHookEvent::BeforeUpdateHookEvent::AfterUpdateHookEvent::BeforeDeleteHookEvent::AfterDelete
save() handles both inserts and updates. For existing models, save() runs both the save hooks and the update hooks.
Models keep a snapshot of their last known persisted state so hooks and application code can inspect diffs.
Available helpers:
$model->getOriginal()$model->getOriginal('status')$model->getDirty()$model->isDirty()$model->isDirty('status')$model->getChanges()$model->wasChanged()$model->wasChanged('status')
Example:
#[Hook(HookEvent::AfterUpdate)]
protected function auditStatusChange(): void
{
if (!$this->wasChanged('status')) {
return;
}
$before = $this->getOriginal('status');
$after = $this->getChanges()['status'] ?? null;
}During AfterSave, AfterUpdate, and AfterDelete hooks, getOriginal() still exposes the pre-write values and getChanges() contains the values written or removed by the operation. After the hooks finish, the model syncs its original snapshot to the latest persisted state.
- only declared properties are accepted during normal model assignment
HasTimestampsmanagescreated_atandupdated_at- models can use a shared manager or a model-specific
$connection - relation methods must return a
Relation/ModelQuerybuilt fromhasOne(),hasMany(), orbelongsTo()