Database Models and Queries
For most app code, start with models and model queries. Drop to raw SQL only when a query is easier to express directly.
Models live in:
app/Models
Generate one:
./myxa make:model App\\Models\\Post
Basic Model
use Myxa\Database\Model\HasTimestamps;
use Myxa\Database\Model\Model;
final class Post extends Model
{
use HasTimestamps;
protected string $table = 'posts';
protected ?int $id = null;
protected string $title = '';
protected string $body = '';
protected ?int $user_id = null;
}
Basic actions:
$post = Post::create([
'title' => 'Hello',
'body' => 'World',
'user_id' => 1,
]);
$found = Post::find(1);
$all = Post::all();
$post->title = 'Updated';
$post->save();
$post->delete();
Querying Models
Basic filtering:
$users = User::query()
->where('status', '=', 'active')
->orderBy('id', 'DESC')
->limit(10)
->get();
Find one record:
$user = User::query()
->where('email', '=', 'john@example.com')
->first();
Require one record:
$user = User::query()->findOrFail(1);
Existence checks:
$exists = User::query()
->where('email', '=', 'john@example.com')
->exists();
Pagination-like slicing:
$posts = Post::query()
->orderBy('id', 'DESC')
->limit(20, 40)
->get();
Large Result Sets, Cursors, and Batching
Use cursor() to stream models one at a time:
foreach (User::query()
->where('status', '=', 'active')
->orderBy('id')
->cursor() as $user) {
// $user is a hydrated User model.
}
You can also stream directly from the model, with optional limit and offset arguments:
foreach (User::cursor(limit: 500) as $user) {
// Handle one model at a time.
}
Use chunk() when your work is naturally batch-oriented:
User::query()
->where('status', '=', 'active')
->orderBy('id')
->chunk(100, function (array $users, int $page): void {
foreach ($users as $user) {
// Handle a batch of hydrated User models.
}
});
Return false from the chunk callback to stop early:
$completed = User::chunk(100, function (array $users, int $page): bool {
// Stop after the first batch.
return false;
});
// $completed === false
Use a stable orderBy() when streaming or chunking records so processing is predictable.
Joins
Simple join:
$users = User::query()
->select('users.id', 'users.email', 'profiles.display_name')
->join('profiles', 'profiles.user_id', '=', 'users.id')
->where('users.status', '=', 'active')
->orderBy('users.id', 'DESC')
->get();
More advanced join clauses are available too:
$users = User::query()
->select('users.id', 'profiles.display_name')
->leftJoin('profiles', static function ($join): void {
$join->on('profiles.user_id', '=', 'users.id')
->where('profiles.status', '=', 1);
})
->get();
Relationships
Example relations:
final class User extends Model
{
protected string $table = 'users';
protected ?int $id = null;
protected string $email = '';
public function posts(): \Myxa\Database\Model\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(): \Myxa\Database\Model\ModelQuery
{
return $this->belongsTo(User::class);
}
}
Eager loading:
$users = User::query()
->with('posts', 'sessions')
->orderBy('id')
->get();
Nested eager loading:
$users = User::query()
->with('posts.comments')
->get();
Relationship query:
$user = User::findOrFail(1);
$posts = $user->posts()
->where('published', '=', 1)
->orderBy('id', 'DESC')
->get();
Declared Properties
Myxa models are strict. Persisted fields should be declared as real PHP properties on the model class.
Good examples:
protected string $email = '';
protected ?string $name = null;
protected ?int $user_id = null;
Practical rules:
- if a field belongs to the model, declare it
- if a field may be missing, make it nullable or give it a sensible default
- if you use typed properties without defaults, initialize them before relying on them
- metadata properties like
$table,$primaryKey, and$connectionare separate from normal persisted attributes
Normal writes are strict:
fill([...])accepts only declared, non-guarded propertiessetAttribute()accepts only declared model properties$model->property = ...follows the same rule- unknown attributes throw an exception during normal writes
Guarded, Hidden, and Internal Attributes
The framework supports attribute metadata on model properties:
use Myxa\Database\Attributes\Guarded;
use Myxa\Database\Attributes\Hidden;
use Myxa\Database\Attributes\Internal;
final class User extends Model
{
protected string $table = 'users';
protected ?int $id = null;
protected string $email = '';
#[Guarded]
#[Hidden]
protected ?string $password_hash = null;
#[Internal]
protected string $helperLabel = 'draft';
}
Behavior:
#[Guarded]skips the property duringfill([...])#[Hidden]excludes it fromtoArray()and JSON serialization#[Internal]removes it from normal persisted model field handling entirely
Casting
Models support property-level casts through the #[Cast(...)] attribute.
Built-in cast types supported by the core framework today:
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 ?int $id = null;
protected string $email = '';
#[Cast(CastType::DateTimeImmutable, format: DATE_ATOM)]
protected ?DateTimeImmutable $created_at = null;
#[Cast(CastType::Json)]
protected ?array $settings = null;
}
Notes:
- hydrated datetime strings are cast into
DateTimeorDateTimeImmutable - hydrated JSON strings are decoded when using
CastType::Json nullvalues are left asnull- serialized output converts datetime values back to strings
- SQL persistence stores JSON-cast attributes as JSON strings
- invalid values throw an
InvalidArgumentException
Extra Hydrated Columns
Normal writes are strict, but hydrated rows may still contain additional columns from trusted storage data.
For example, computed selects or joined aliases can still exist on a hydrated model:
$user = User::hydrate([
'id' => 1,
'email' => 'john@example.com',
'computed_label' => 'Admin',
]);
$user->getAttribute('computed_label'); // 'Admin'
Important distinction:
- declared properties are the normal writable model fields
- extra hydrated attributes can still exist on trusted loaded data
- those extra values are available through
getAttribute() - they may appear in serialization unless hidden
- they are not part of the normal declared writable model contract
Further Reading
- Database
- Database Migrations
- Validation
vendor/200mph/myxa-framework/src/Database/Model/README.mdvendor/200mph/myxa-framework/src/Database/Query/README.md