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.
./orbit make:model Note --fields=title:string,body:string,views:int<?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
$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
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
$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 idexists() 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
$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',
);