strukt / ar
A small query builder and active record ORM for SQL databases.
Requires
- php: >=8.2
- strukt/base: v1.0.0
- strukt/commons: v1.1.0
- strukt/console: v2.0.0
Requires (Dev)
- pestphp/pest: ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-25 16:54:56 UTC
README
A small query builder and active record ORM for SQL databases. It sits directly on PDO, parameterizes every statement, and keeps the moving parts small enough to read in one sitting.
A single namespace covers the data-access and active record layers:
Strukt\Ar |
|
|---|---|
| Style | immediate, explicit writes |
| Identity | one object per call |
| Writes | save()/delete() run at once |
| Reads | query builder per query |
| Use | low-level control |
Managed entities — an identity map, a unit of work, and result caching over the very
same models — live in the separate strukt/orm package; see the install note below.
Install
composer require strukt/ar
The package needs at least PHP 8.2 and a PDO driver for your database. For managed
entities — identity map, unit of work, result cache layered over these very same
models and connection — install strukt/orm as well; its README documents how the
two layers interoperate.
Connections
A Strukt\Ar\Connection wraps a PDO instance. Open one through a named constructor and
register it with the manager; the db() helper resolves the default connection from
then on.
use Strukt\Ar\Connection; Connection::sqlite(':memory:')->asDefault(); // this is what db() returns Connection::sqlite('app.db')->as('archive'); // named, opt in db(); // the default connection db('archive'); // a named connection
mysql() and pgsql() take host, credentials, and similar connection options. pdo()
wraps an existing client. A transaction runs inside transaction(), which commits on
success and rolls back when the callback throws; nested calls share the outer
transaction.
db()->transaction(function (Connection $connection) { $connection->execute('UPDATE accounts SET balance = balance - ? WHERE id = ?', [25, 7]); $connection->execute('UPDATE accounts SET balance = balance + ? WHERE id = ?', [25, 9]); });
Connections from environment files
Connection::fromEnv() loads .env file(s) through strukt/base's Env and opens the
connection they describe, registering it as the default. Files are read in order, so a
later file overrides an earlier one — the classic .env plus .env.local pattern.
DB_CONNECTION=sqlite DB_DATABASE=app.db
Connection::fromEnv('.env', '.env.local'); // defaults to ['.env'];
Keys are read with env(), falling back to the process environment and then to
defaults: DB_CONNECTION (sqlite), DB_DATABASE (database.sqlite),
DB_HOST (127.0.0.1), DB_USERNAME (root), DB_PASSWORD (empty), and
DB_PORT (3306 for mysql, 5432 for pgsql).
The query builder
table() starts a fluent, always parameterized query against one table. Every value is
bound, never interpolated, so clauses are safe by construction.
db()->table('users') ->where('active', 1) ->where('age', '>', 21) ->orderBy('name') ->limit(10) ->get();
where() accepts equality shorthand, an explicit operator, or a map of equalities;
orWhere(), whereIn(), whereNotIn(), whereNull(), whereNotNull(), and
whereBetween() cover the rest. join() and leftJoin() join without adding
bindings, so a query stays legible.
db()->table('posts') ->join('users', 'users.id', 'posts.user_id') ->select(['users.name', 'posts.title']) ->groupBy('users.name') ->get();
Reading gives you rows, single values, or aggregates:
db()->table('users')->where('active', 1)->count(); // 5 db()->table('users')->sum('balance'); // 412.5 db()->table('users')->pluck('name', 'id'); // [1 => 'Sam', ...] db()->table('users')->where('id', 7)->value('name'); // 'Sam' db()->table('users')->where('email', $email)->exists(); // true
The last row inside a transaction() insert surfaces through insertGetId(). Writes
are guarded the same way: delete() refuses to run without a where() clause, and
update()/increment()/decrement() touch only the matching rows.
Clauses accumulate on the instance. Keep a base query, then clone it for variations:
$base = db()->table('users')->where('active', 1); $admins = (clone $base)->where('admin', 1)->get(); $staff = (clone $base)->where('admin', 0)->get();
toSql() and bindings() expose the compiled statement for debugging.
Active record
Extend Strukt\Ar\Model. One instance is one row: create, read, update, delete, and
their relationships all run through the query builder above, so the two layers compose.
class User extends Model { protected static array $fillable = ['name', 'email', 'is_admin']; public function posts(): array { return $this->hasMany(Post::class); } } $sam = User::create(['name' => 'Sam', 'email' => 'sam@strukt.test']); $sam->name; // 'Sam', via the columns of the row $sam->is_admin = true; $sam->save(); User::find(1); // reloads the row User::where('is_admin', 0)->orderBy('name')->get(); User::first();
Table names and keys
The table name is derived from the class short name, pluralized and snake-cased:
PostComment maps to post_comments. Override it, or the key, when the schema differs:
class User extends Model { protected static ?string $table = 'people'; protected static string $keyName = 'uuid'; }
Fillable, casts, and timestamps
fillable guards every mass assignment through create(), fill(), and update();
a column must be listed before it can be set that way. Individual columns always accept
direct assignment through the property or setAttribute().
casts map how values enter and leave the database. Dates become DateTimeImmutable
on read and return to Y-m-d H:i:s on write; bool, int, float, string, and
array round trip their JSON representation automatically.
Timestamps are maintained when $timestamps is left at its default true:
$sam = User::create([...]); $sam->created_at; // '2026-10-02 16:52:58' $sam->updated_at; // set again on every save()
The lifecycle
$sam = User::findOrFail(7); // or types a clear ModelNotFoundException $sam->update(['name' => 'Samantha']); $sam->refresh(); // reloads the row in place $sam->delete(); // false when the model does not exist yet $sam->exists(); // whether the row is in the database $sam->key(); // the primary key value $sam->toArray(); // visible, cast columns $sam->toJson(); // the same, as JSON
save() inserts when the model is new and updates otherwise, touching only the
columns that changed. fresh reads always go through the query builder, so nothing is
cached at the query layer.
Relationships
Describe a relationship as a method that delegates to hasMany(), hasOne(), or
belongsTo(). Foreign keys are derived from the class names and can be overridden.
Results are computed on the first call and remembered for the life of the instance.
class Post extends Model { public function author(): ?User { return $this->belongsTo(User::class); } public function comments(): array { return $this->hasMany(Comment::class); } } $post->author()->name; $user->posts(); // Post[] $user->posts()[0]->comments(); // nested, still one query per relation
Magical properties only
Model columns are read through __get/__set. Requesting a column that is neither an
attribute nor a declared relationship throws OutOfBoundsException, so a typo fails
loudly instead of returning null.
Managed entities via strukt/orm
For an identity map, a unit of work, and result caching over these very same models,
install the strukt/orm package:
composer require strukt/orm
It layers managed-entity semantics on top of untouched Strukt\Ar models and the
default connection, so the two layers read and write the same rows through the same
classes:
$sam = orm()->find(User::class, 7); // one instance per row $sam->name = 'Samantha'; // nothing written yet orm()->commit(); // one transaction
See strukt/orm's README for the full contract, especially how direct Ar writes
interact with its result cache and identity map.
Generating models from the database
The package ships a model:generate command that introspects a database schema and
writes Ar\Model subclasses. It runs through the strukt/console runtime, so the
same docblock drives the CLI, --help, and generated documentation.
php bin/dbcli model:generate --database=app.db --driver=sqlite # restrict to one table, or pick a namespace and directory php bin/dbcli model:generate --database=app.db --driver=sqlite \ --table=users --namespace=App\Models --dir=src/Models
MySQL and PostgreSQL add host and credentials:
php bin/dbcli model:generate --driver=mysql --database=app \
--host=127.0.0.1 --user=root --password=secret --dir=src/Models
php bin/dbcli model:generate --driver=pgsql --database=app \
--host=127.0.0.1 --user=app --dir=src/Models
Omit --database to pick the connection from a dotenv file instead. --env lists
the file(s), defaults to .env, and accepts comma-separated values.
php bin/dbcli model:generate --dir=src/Models # from .env
php bin/dbcli model:generate --env=.env,.env.local
For every table the generator derives the class name (singular, PascalCase), pins
$table, selects a $keyName when the primary key is not id, disables
$timestamps when the table has no created_at/updated_at, and fills $casts from
the column types and $fillable with everything except the key and the timestamp
columns:
// blog_posts -> BlogPost; pk id; no timestamp columns class BlogPost extends Model { protected static ?string $table = 'blog_posts'; protected static bool $timestamps = false; protected static array $fillable = ['author_id', 'title', 'published_at', 'views']; protected static array $casts = [ 'id' => 'int', 'author_id' => 'int', 'published_at' => 'datetime', 'views' => 'int', ]; }
When the package is installed as a dependency, the command is available as
vendor/bin/dbcli. Use list to see the available commands.
Creating the database from models
The reverse of model:generate is db:create: it scans a directory for models
that declare a $schema and builds their tables, without needing a hand-written
CREATE TABLE. The primary key and, when $timestamps is on, the
created_at/updated_at columns are generated for you.
class User extends Model { protected static array $schema = [ 'name' => ['type' => 'string', 'nullable' => false], 'email' => ['type' => 'string', 'nullable' => false, 'unique' => true], 'is_admin' => ['type' => 'bool', 'nullable' => false, 'default' => false], 'birthday' => ['type' => 'datetime', 'nullable' => true], ]; }
php bin/dbcli db:create --database=app.db --driver=sqlite --dir=src # restrict discovery to one namespace php bin/dbcli db:create --database=app.db --driver=sqlite \ --dir=src --namespace=App\Models php bin/dbcli db:create --driver=mysql --database=app --user=root \ --password=secret --dir=src/Models
Omit --database to pick the connection from a dotenv file instead (see
--env, which defaults to .env):
php bin/dbcli db:create --dir=src/Models # from .env
Models without a declared $schema are skipped, and running the command again is
a no-op thanks to IF NOT EXISTS. Described types map to SQLite storage classes:
int/bool to INTEGER, float to REAL, string/text/datetime/json to TEXT, and blob
to BLOB.
Tests
composer test
The suite runs against an in-memory SQLite connection with the same schema the code
examples use, and covers connections, the query builder, the active record layer, and
the model:generate schema generator on top of it.