Models

A typed, mutable view of one table — Note::find(1), $note->save() — built entirely on the query builder.

PhpOrbit\Database\Model maps one table to one class: typed properties, static finders, and save()/delete() on the instance. It adds no SQL capability the query builder does not already have — every method here is built on Query, the same builder $database->query('table') returns. Joins, aggregates beyond count(), and relationships are still Connection::select()'s job.

Shell
./orbit make:model Note --fields=title:string,body:string,views:int
PHP
<?php

namespace App\Models;

use PhpOrbit\Database\Model;

final class Note extends Model
{
    public string $title = '';
    public string $body = '';
    public int $views = 0;

    protected static function table(): string
    {
        return 'notes';
    }

    protected static function fromRow(array $row): static
    {
        $model = new static();
        $model->title = (string) ($row['title'] ?? '');
        $model->body = (string) ($row['body'] ?? '');
        $model->views = (int) ($row['views'] ?? 0);

        return $model;
    }

    public function toRow(): array
    {
        return ['title' => $this->title, 'body' => $this->body, 'views' => $this->views];
    }
}

fromRow() and toRow() are the only two methods a model writes by hand — the same “narrow once, at the boundary” shape as Connection::narrowRow(), just per table instead of per driver. --fields writes both from a name:type list; edit them freely afterwards.

Wiring it up

Static finders need a connection without a container to resolve one from. Point every model at one, once, in app/bootstrap.php — right where Connection is already registered as a singleton:

PHP
<?php
$database = Connection::connect(DatabaseSettings::fromEnvironment($env, $root));

Model::useConnection($database);

$app->container->singleton(Connection::class, static fn (): Connection => $database);

A second call throws — the same shape as registering a container service twice. This is boot-time wiring, not something a request should ever reach.

Reading

PHP
<?php
Note::find(1);                              // ?Note
Note::findOrFail(1);                        // Note, or throws ModelNotFound
Note::all();                                // list<Note>
Note::count();                              // int

Note::where('title', '=', 'Hello')->get();  // list<Note>
Note::where('views', '>', 100)->first();    // ?Note

Note::query()
    ->where('views', '>', 10)
    ->orderBy('views', Direction::Descending)
    ->limit(5)
    ->get();

where() and query() return a ModelQuery — the same fluent chain as Query (whereNull(), whereIn(), orderBy(), limit(), offset(), affectingEveryRow()), except get() and first() hydrate instances instead of handing back arrays. update() and delete() on a chain stay row-based — a bulk write over many rows has no single instance to return.

Writing

PHP
<?php
$note = new Note();
$note->title = 'Hello';
$note->body = 'World';
$note->save();          // INSERT — $note->id is now set

$note->title = 'Updated';
$note->save();          // UPDATE, keyed on id — exists() is now true

$note->delete();        // DELETE, keyed on id

exists() tells the two cases apart: a freshly-newd instance inserts on save(), one loaded through find()/all()/where() updates. Calling delete() on an instance that was never saved throws — there is no row to remove.

Worker safety

A model's one static is the shared connection set by useConnection(). That is not the hazard the rest of this framework forbids static state for: under a worker, Connection is already one object shared by every request in the process, and registering it as a container singleton carries the same sharing. Pointing Model at that same instance adds no new risk, because nothing per request is ever cached here — find(), all() and every ModelQuery terminal method build a fresh instance on every call. What would reintroduce the hazard — memoising a row, or a query result, on a static property — this class never does.

What a model is not

There are no relationships, no eager loading, no query scopes, and no events. A model past one table's worth of typed columns is Connection::select()'s job, the same as with the query builder:

PHP
<?php
$rows = $database->select(
    'SELECT n.*, COUNT(c.id) AS comment_count
       FROM notes n
       LEFT JOIN comments c ON c.note_id = n.id
      GROUP BY n.id',
);