jam / dbsimple-models
ActiveRecord / Model layer for Jam DbSimple
Requires
- php: >=8.4
- jam/dbsimple: dev-main || ^1.0
Requires (Dev)
- phpunit/phpunit: ^10.0 || ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-21 19:03:56 UTC
README
A lightweight, high-performance Active Record and Model persistence layer for PHP 8.4+, built specifically on top of jam/dbsimple.
Why
Most ORMs load relations imperatively: you call something, it fetches something, maybe lazily, maybe N+1.
Here the data graph is described, not coded. with() takes a compact expression that says which
relations, which columns, under which conditions, in which order and in which output shape you need —
and the ORM turns every relation into exactly one WHERE fk IN (...) query for the whole result set.
Post::instance() ->with('author(id, name):c, tags!:o(name), comments[is_approved = 1]:o(id).author(id, name)') ->all();
Because the description is just a string (or an array), it composes. A model can publish a with-profile — a function that builds the expression from parameters — and every caller decides, at the call site, who gets what data and when: a list page, a detail page, a logged-in viewer, a nested context. See With-profiles below.
Features
- Declarative eager loading (no lazy loading, no N+1):
- One expression describes the whole graph:
alias[condition](fields)^exclude^?!:flags.nested. - Per-relation SQL conditions with references to the parent row (
[user_id <> this(user_id)]), column projection, ordering, limits,keyBy, pivot rows, soft-deleted rows. - Output shape is part of the description: computed fields (
:c), excluded fields (^...^),{}/[]instead ofnull(!). - Expressions merge by path and are plain strings, so they compose into reusable, parameterized with-profiles.
- One expression describes the whole graph:
- Active Record Pattern:
- Expressive CRUD operations:
first(),all(),collection(),value(),column(),save(),update(),delete(). - Fluent Query Builder methods (
where(),join(),leftJoin(),limit(),offset(),groupBy(),orderBy()) with DbSimple placeholders (?,?d,?a,?#,?s) and optional{...}blocks.
- Expressive CRUD operations:
- Rich Relations:
hasOne,hasMany,belongs, many-to-many (hasManythrough a pivot model), polymorphic and in-code (DummyModel) relations.
- Data Casting & Transformations:
- Typed fields (
bool,int,uint,float,date,datetime,json,delim,flags,ip). - Automatic JSON sub-property mapping and bi-directional casting.
- Typed fields (
- Computed & Dynamic Properties:
- Declarative
$computedproperties with lazy evaluation. - Flag-based array/object conversions via
toArray(':c')andtoObject().
- Declarative
- Master / Replica (Slave) Support:
- Multiple named connections;
->db(Model::DB_SLAVE)switches a query (and its eager-loaded relations) to a replica.
- Multiple named connections;
- Model Events Lifecycle:
- Hooks for
retrieved,creating,created,updating,updated,deleting,deleted.
- Hooks for
Requirements
- PHP 8.4+
jam/dbsimple(MySQL via PDO is the primary target; the generated SQL uses the MySQL dialect)
Upgrading existing models: Model::$types is declared without a type, so a model must
declare it as protected $types = [...] (protected array $types is a fatal error), and an
overridden where() must accept string|array|Criteria|null. See CHANGELOG.md
for all breaking and behaviour changes.
Slug transliteration (SlugEvaluatable) is replaceable: implement
Jam\Models\Utils\Translit\TransliteratorInterface and register it with
Strings::setTransliterator(), or wrap a function:
Strings::setTransliterator(new CallableTransliterator('translit')). A model can override
protected static function slugTransliterator(). Without it, a global translit() function
(if the application declares one) or the built-in Strings::translit() is used.
Installation
composer require jam/dbsimple-models
Quick Setup
1. Initialize Database Connections
Register your Jam\DbSimple\DatabaseInterface instances with Model::initDbSimple():
use Jam\DbSimple\Connect; use Jam\Models\Model; $master = new Connect('mypdo://user:pass@127.0.0.1/app_db?enc=utf8mb4'); Model::initDbSimple([ Model::DB_MASTER => $master, ]);
2. Define a Model
use Jam\Models\Model; use Jam\Models\Traits\Timestamps; class User extends Model { use Timestamps; protected string $fields = 'id, email, first_name, last_name, is_active, tags, created_at, updated_at'; protected string $computed = 'full_name'; protected $types = [ 'bool' => 'is_active', 'delim' => 'tags', ]; public function full_name(): string { return trim($this->first_name . ' ' . $this->last_name); } }
3. Usage Examples
Finding & Querying
Queries start from a builder instance: Model::instance().
// Find by primary key (first() returns an empty model, not null, when nothing is found) $user = User::instance()->id(42)->first(); if ($user->isEmpty()) { /* 404 */ } // Query with conditions $activeUsers = User::instance() ->where('is_active = ?d', 1) ->where('email LIKE ?', '%@example.com') ->orderBy('created_at DESC') ->limit(10) ->collection(); // Eager load relations (one query per relation, no N+1) and export with computed fields $users = User::instance()->with('posts(id, title):o(id DESC), profile')->all(); $usersArray = $users->toArray(':cd'); // A requested relation with no related rows stays null (empty() is true, omitted from toArray()), // but is marked as loaded, so "loaded but empty" differs from "not requested": empty($users[0]->posts); // true when the user has no posts $users[0]->isRelationLoaded('posts'); // true: loaded (maybe empty), no lazy re-query needed
Creating & Updating
$user = new User([ 'email' => 'alex@example.com', 'first_name' => 'Alex', 'last_name' => 'Voxel', 'is_active' => true, 'tags' => ['developer', 'php'], ]); $user->save(); // Update (save() writes all loaded fields; update() — only the listed ones) $user->first_name = 'Alexander'; $user->update('first_name');
Storage backends
A model delegates reading and writing rows to a storage (Jam\Models\Storage\StorageInterface:
select, aggregate, insert, update, increment, delete). Fields, casting, relations,
events and toArray() stay in the model.
| Storage | Used by | Conditions |
|---|---|---|
SqlStorage |
every model by default (DbSimple: MySQL, PostgreSQL, SQLite) | array conditions and SQL fragments |
MemoryStorage |
DummyModel (read-only lists defined in code) |
array conditions, simple SQL (=, IN) |
| your own | a model overriding createStorage() |
array conditions |
Array conditions are storage-independent:
Post::instance()->where([ 'user_id' => [1, 2], // IN 'views' => ['>=' => 10], 'deleted_at' => null, // IS NULL Criteria::OR => [['pinned' => 1], ['title' => ['like' => 'PHP%']]], ])->all();
Eager loading only needs "rows where key IN (...)" from each storage, so relations work across
storages (a SQL model can hasMany models kept elsewhere and vice versa) — see
examples/11-custom-storage.php and the reference in-memory
implementation in tests/Support/ArrayStorage.php.
SQL fragments, joins, GROUP BY and locks remain SQL-only.
With-profiles: describe the data, let the caller choose
with() is a description of a data graph, so the natural unit of reuse is a function that builds
that description. Put it on the model: the model knows what can be loaded around it, the caller knows
what is needed right now.
class Post extends BaseModel { public static function commonWithString( string $name = '', // attach the profile to a path: '', 'posts:o(id)', 'subject', ... int $viewerId = 0, // who is looking — conditions may depend on it bool $comments = false, // detail page only bool $commentsDeep = false, bool $full = false, ): string { // Always: the author (only needed columns + computed) and tags in a stable order $with = [ 'author(id, name, email, role_id):c', 'author.role(id, code)', 'tags!:o(name)', // ! — [] instead of null in toArray() ]; if ($comments) { // Guests see approved comments, a signed-in user also sees their own pending ones $with[] = $viewerId ? sprintf('comments[is_approved = 1 OR user_id = %d]!:o(id)', $viewerId) : 'comments[is_approved = 1]!:o(id)'; if ($commentsDeep) { // The same path again: nodes are merged, condition and flags are declared once above $with[] = 'comments.author(id, name)'; $with[] = 'comments.author.profile(id, user_id, bio)'; } } if ($full) { $with[] = 'category(id, parent_id, title).parent(id, title)'; $with[] = 'author.profile!'; } // Prefix every path with $name: 'author' -> 'posts:o(id).author', '[x]' -> 'posts[x]' return implode(', ', array_unique(ArrayHelper::extendsWithName($with, $name))); } }
The call site reads like a specification — named arguments tell exactly what is fetched and for whom:
// List page Post::published()->with(Post::commonWithString())->all(); // Post page for the current user Post::instance() ->with(Post::commonWithString(viewerId: $me->id, comments: true, commentsDeep: true, full: true)) ->id($id) ->first();
Profiles nest. The same post profile is attached under another model's relation by passing a path:
class User extends BaseModel { public static function commonWithString(string $name = '', int $viewerId = 0, bool $posts = false): string { $with = ArrayHelper::extendsWithName(['profile!', 'role(id, code, title)'], $name); if ($posts) { // Pass the FULL path into the nested profile: extendsWithName() applied to an already // joined string would prefix only its first element [$postsPath] = ArrayHelper::extendsWithName(['posts[status = 1]:o(id DESC)'], $name); $with[] = Post::commonWithString($postsPath, $viewerId); } return implode(', ', array_unique($with)); } } User::instance()->with(User::commonWithString(posts: true))->id(1)->first(); Comment::instance()->with(User::commonWithString('author', posts: true))->all(); // two levels down
What this gives you:
- One place per model describes what can be loaded around it; pages don't copy-paste
withstrings. - Control at the call site: parameters switch whole branches of the graph on and off, so a list page never pays for detail-page data.
- Viewer-dependent data is just a condition in the description (
rates[user_id = %d],comments[is_approved = 1 OR user_id = %d]), still one query per relation for the whole page. - Stable API shape:
!gives{}/[]instead ofnull,(fields)and^exclude^limit what leaks out. - Polymorphic and heterogeneous paths:
?marks a relation that only some classes have (subject.tags?whensubjectis aPostor aComment) — no exception for the others. - Readable: the profile is a plain string — dump it, log it, diff it.
Runnable version: examples/11-with-profiles.php;
the full with() syntax: examples/06-eager-loading.php
and the cheat sheet in examples/README.md.
with() syntax at a glance
alias[condition](fields)^exclude^?!:flags.nested, other
| Part | Example | Meaning |
|---|---|---|
[...] |
comments[is_approved = 1] |
SQL condition for the related table; this(field) — a field of the parent row |
(...) |
author(id, name) |
columns to select (relation keys are added automatically) |
^...^ |
author^email^ |
exclude fields from toArray() |
? |
subject.tags? |
unknown alias is ignored instead of throwing |
! |
profile! |
{} / [] in toArray() instead of a missing relation |
:o(...) |
:o(views DESC|id) |
ORDER BY (a comma inside a flag argument is written as |) |
:l(N), :f(N) |
:l(10) |
LIMIT / OFFSET of the relation query (for the whole query, not per parent) |
:k(field) |
:k(type) |
key the relation list by a field in toArray() |
:c |
author:c |
computed fields of the relation |
:p |
tags:p |
pivot rows in @pivot |
:r |
posts:r |
include soft-deleted rows |
A path may be mentioned many times — 'author(id, name)', 'author.role', 'author.profile' — the nodes
are merged (fields, conditions and flags are combined).
Documentation by example
The examples/ directory contains runnable scripts (in-memory SQLite, nothing to set up)
covering model definition, CRUD, the query builder, casting, relations, eager loading syntax,
with-profiles, saving object graphs, API output flags, events / timestamps / soft deletes and master-replica setups,
plus a list of best practices and pitfalls: see examples/README.md.
php examples/06-eager-loading.php php examples/11-with-profiles.php
Tests
composer install vendor/bin/phpunit # unit + integration tests on in-memory SQLite # the same integration suite (plus MySQL-only features) on MySQL / MariaDB DBSIMPLE_TEST_MYSQL=mysql://user:pass@127.0.0.1/dbsimple_test vendor/bin/phpunit
jam/dbsimple is resolved from a sibling ../dbsimple checkout (see repositories in composer.json).
License
This library is licensed under the GNU Lesser General Public License v2.1.