vaibhavpandeyvpz / datum
A simple Active Record ORM for PHP built on top of databoss
Installs: 20
Dependents: 1
Suggesters: 0
Security: 0
Stars: 2
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/vaibhavpandeyvpz/datum
Requires
- php: ^8.2
- psr/clock: ^1.0
- psr/event-dispatcher: ^1.0
- vaibhavpandeyvpz/databoss: ^2.1
Requires (Dev)
- phpunit/phpunit: ^10.0
- vaibhavpandeyvpz/samay: ^1.0
- vaibhavpandeyvpz/soochak: ^2.0
README
A simple Active Record ORM for PHP built on top of vaibhavpandeyvpz/databoss.
Features
- Active Record Pattern: Simple, intuitive model definitions
- Fluent Query Builder: Chain methods like
where(),sort(),limit(),offset(),get(),first() - Relationships: Support for
one(has one),many(has many),owner(belongs to), andowners(belongs to many) relationships - Attribute Casting: Automatic type conversion for DateTime, arrays, JSON, integers, floats, and booleans
- Automatic Timestamps: Automatically manages
created_atandupdated_attimestamps (enabled by default) - UUID Primary Keys: Support for UUID primary keys with automatic UUID v4 generation
- Lazy Connection Loading: Connection factory support for lazy database connection creation
- PSR-14 Event Dispatcher: Model lifecycle events (creating, created, updating, updated, deleting, deleted, saving, saved)
- Built on Databoss: Leverages the powerful databoss filtering syntax
- Multi-Database Support: Works with MySQL, PostgreSQL, SQLite, and SQL Server
- Type-safe: Full PHP 8.2+ type declarations
- Well Tested: 90%+ code coverage with comprehensive test suite
Requirements
- PHP >= 8.2
- PDO extension
- One of:
ext-pdo_mysql,ext-pdo_pgsql,ext-pdo_sqlite, orext-pdo_sqlsrv(depending on your database)
Installation
composer require vaibhavpandeyvpz/datum
Or if you want to install the databoss dependency separately:
composer require vaibhavpandeyvpz/databoss ^2.1 composer require vaibhavpandeyvpz/datum
Quick Start
Setting Up the Connection
You can set the connection directly or use a connection factory for lazy connection creation using the connect() method:
Direct Connection
<?php use Databoss\Connection; use Datum\Model; // Create a databoss connection $connection = new Connection([ Connection::OPT_DATABASE => 'mydb', Connection::OPT_USERNAME => 'root', Connection::OPT_PASSWORD => 'password', ]); // Set the connection for all models Model::connect($connection);
Connection Factory (Lazy Loading)
<?php use Databoss\Connection; use Datum\Model; // Set a connection factory that will be called lazily when needed Model::connect(function () { return new Connection([ Connection::OPT_DATABASE => 'mydb', Connection::OPT_USERNAME => 'root', Connection::OPT_PASSWORD => 'password', ]); }); // The connection will only be created when you first use a model $user = User::find(1); // Connection is created here
Defining a Model
<?php use Datum\Model; class User extends Model { protected static ?string $table = 'users'; protected static string $primaryKey = 'id'; /** * Define attribute casts for automatic type conversion. * * @var array<string, string> */ protected static array $casts = [ 'age' => 'int', 'created_at' => 'datetime', 'metadata' => 'array', 'is_active' => 'bool', ]; }
Basic CRUD Operations
// Create $user = new User([ 'name' => 'John Doe', 'email' => 'john@example.com', ]); $user->save(); // created_at and updated_at are automatically set // Read $user = User::find(1); $user = User::findOrFail(1); // Throws exception if not found // Update $user->name = 'Jane Doe'; $user->save(); // updated_at is automatically updated // Delete $user->delete(); // Get all $users = User::all();
Automatic Timestamps
Datum automatically manages created_at and updated_at timestamps by default. When you save a model:
- On Insert: Both
created_atandupdated_atare automatically set to the current timestamp - On Update: Only
updated_atis automatically updated
$user = new User(['name' => 'John', 'email' => 'john@example.com']); $user->save(); // created_at and updated_at are set automatically // Later... $user->name = 'Jane'; $user->save(); // updated_at is automatically updated, created_at remains unchanged
Disabling Timestamps:
If you want to disable automatic timestamps for a model, set the $timestamps property to false:
class User extends Model { protected static bool $timestamps = false; }
Custom Timestamp Column Names:
You can customize the timestamp column names:
class User extends Model { protected static string $createdAt = 'created_at'; protected static string $updatedAt = 'updated_at'; }
Manually Setting Timestamps:
You can still manually set timestamps, and they will be respected:
$user = new User([ 'name' => 'John', 'email' => 'john@example.com', 'created_at' => '2020-01-01 10:00:00', 'updated_at' => '2020-01-02 10:00:00', ]); $user->save(); // Your custom timestamps are preserved
Using PSR-20 Clock:
Datum uses PSR-20 ClockInterface for timestamp generation, allowing you to inject a custom clock implementation for testing or time manipulation:
use Psr\Clock\ClockInterface; use Datum\Model; // Set a custom clock Model::clock($yourClockInstance);
For testing, you can use vaibhavpandeyvpz/samay to control time:
use Samay\FrozenClock; use Datum\Model; // Freeze time at a specific moment $frozenTime = new \DateTimeImmutable('2024-01-15 10:30:00'); Model::clock(new FrozenClock($frozenTime)); $user = new User(['name' => 'Test']); $user->save(); // Will use the frozen time for timestamps
UUID Primary Keys
Datum supports UUID (Universally Unique Identifier) primary keys in addition to auto-incrementing integer IDs. When using UUIDs, Datum will automatically generate a UUID v4 before inserting the model into the database.
Setting Up a Model with UUID Primary Key:
class Item extends Model { protected static ?string $table = 'items'; protected static string $primaryKey = 'uuid'; protected static bool $incrementing = false; }
Database Schema Example:
For MySQL:
CREATE TABLE "items" ( "uuid" CHAR(36) NOT NULL, "name" VARCHAR(255) NOT NULL, PRIMARY KEY ("uuid") );
For PostgreSQL:
CREATE TABLE "items" ( "uuid" UUID NOT NULL, "name" VARCHAR(255) NOT NULL, PRIMARY KEY ("uuid") );
For SQLite:
CREATE TABLE "items" ( "uuid" TEXT NOT NULL, "name" VARCHAR(255) NOT NULL, PRIMARY KEY ("uuid") );
Usage:
// UUID is automatically generated on insert $item = new Item(['name' => 'My Item']); $item->save(); echo $item->uuid; // e.g., "550e8400-e29b-41d4-a716-446655440000" // You can also manually set a UUID $item = new Item([ 'uuid' => '550e8400-e29b-41d4-a716-446655440000', 'name' => 'Custom UUID Item' ]); $item->save(); // Find by UUID $item = Item::find('550e8400-e29b-41d4-a716-446655440000');
Key Points:
- Set
protected static bool $incrementing = false;to enable UUID primary keys - Set
protected static string $primaryKey = 'uuid';(or your UUID column name) - UUIDs are automatically generated as UUID v4 if not provided
- You can manually set UUIDs if needed
- All standard operations (find, update, delete) work with UUIDs
Attribute Casting
Datum supports automatic type casting for attributes. Define casts in your model's $casts property:
class User extends Model { protected static array $casts = [ 'age' => 'int', 'created_at' => 'datetime', 'metadata' => 'array', 'is_active' => 'bool', ]; }
Supported Cast Types:
intorinteger- Casts to integerfloatordouble- Casts to floatboolorboolean- Casts to boolean (stored as 0/1 in database)string- Casts to stringarrayorjson- Automatically JSON encodes/decodesdatetimeordate- Casts to/fromDateTimeobjects
Example:
// When loading from database $user = User::find(1); $user->created_at; // DateTime object $user->metadata; // Array (decoded from JSON) $user->age; // Integer // When setting values $user->created_at = new DateTime('2024-01-15'); $user->metadata = ['role' => 'admin']; $user->age = 25; $user->save(); // Values are automatically cast for storage
Querying
// Using where() with databoss filter syntax $users = User::where(['status' => 'active'])->get(); $user = User::where(['email' => 'john@example.com'])->first(); // Complex queries $users = User::where(['age{>}' => 18]) ->sort('created_at', 'DESC') ->limit(10) ->get(); // Count $count = User::where(['status' => 'active'])->count(); // Check existence $exists = User::where(['email' => 'john@example.com'])->exists();
Relationships
Has One
class User extends Model { public function profile() { return $this->one(Profile::class, 'user_id'); } } // Usage $user = User::find(1); $profile = $user->profile; // Automatically loaded
Has Many
class User extends Model { public function posts() { return $this->many(Post::class, 'user_id'); } } // Usage $user = User::find(1); $posts = $user->posts; // Array of Post models
Belongs To (Owner)
class Post extends Model { public function user() { return $this->owner(User::class, 'user_id'); } } // Usage $post = Post::find(1); $user = $post->user; // User model
Belongs To Many (Owners)
class User extends Model { public function roles() { return $this->owners( Role::class, 'user_roles', // pivot table 'user_id', // foreign pivot key 'role_id' // related pivot key ); } } // Usage $user = User::find(1); $roles = $user->roles; // Array of Role models
Model Events
Datum supports PSR-14 compliant event dispatching, allowing you to hook into model lifecycle events. Events are fired at key points during model operations, giving you the ability to perform actions like logging, validation, or side effects.
Setting Up the Event Dispatcher:
You can set the dispatcher directly or use a factory for lazy dispatcher creation:
use Psr\EventDispatcher\EventDispatcherInterface; use Soochak\EventManager; use Datum\Model; // Set dispatcher directly $dispatcher = new EventManager(); Model::dispatcher($dispatcher); // Or use a factory for lazy loading Model::dispatcher(function () { return new EventManager(); }); // Clear dispatcher (operations will work without events) Model::dispatcher(null);
Available Events:
Datum fires the following events during model operations:
Datum\Events\Saving- Fired before saving (both create and update)Datum\Events\Saved- Fired after saving (both create and update)Datum\Events\Creating- Fired before creating a new modelDatum\Events\Created- Fired after creating a new modelDatum\Events\Updating- Fired before updating an existing modelDatum\Events\Updated- Fired after updating an existing modelDatum\Events\Deleting- Fired before deleting a modelDatum\Events\Deleted- Fired after deleting a model
Event Order:
When saving a new model:
SavingeventCreatingevent- Database insert
CreatedeventSavedevent
When updating an existing model:
SavingeventUpdatingevent- Database update
UpdatedeventSavedevent
When deleting a model:
Deletingevent- Database delete
Deletedevent
Listening to Events:
All events implement Psr\EventDispatcher\StoppableEventInterface, allowing you to stop event propagation if needed:
use Datum\Events\Saving; use Datum\Events\Created; use Soochak\EventManager; $dispatcher = new EventManager(); Model::dispatcher($dispatcher); // Listen to saving event $dispatcher->attach(Saving::class, function (Saving $event) { $model = $event->model; echo "Saving model: {$model->name}\n"; // You can stop propagation to abort the operation // $event->stopPropagation(); }); // Listen to created event $dispatcher->attach(Created::class, function (Created $event) { $model = $event->model; echo "Model created with ID: {$model->id}\n"; }); // Create a user $user = new User(['name' => 'John', 'email' => 'john@example.com']); $user->save(); // Events will be fired
Stopping Event Propagation:
You can stop event propagation to abort an operation:
use Datum\Events\Saving; $dispatcher->attach(Saving::class, function (Saving $event) { $model = $event->model; // Validate and stop if invalid if (empty($model->email)) { $event->stopPropagation(); return; } }); $user = new User(['name' => 'John']); // No email $result = $user->save(); // Returns false, model not saved
Complete Example:
use Soochak\EventManager; use Datum\Events\Saving; use Datum\Events\Created; use Datum\Events\Updated; use Datum\Events\Deleting; use Datum\Model; // Setup dispatcher $dispatcher = new EventManager(); Model::dispatcher($dispatcher); // Log all saves $dispatcher->attach(Saving::class, function (Saving $event) { $model = $event->model; error_log("Saving: " . get_class($model) . " #{$model->id}"); }); // Log creates $dispatcher->attach(Created::class, function (Created $event) { $model = $event->model; error_log("Created: " . get_class($model) . " #{$model->id}"); }); // Log updates $dispatcher->attach(Updated::class, function (Updated $event) { $model = $event->model; error_log("Updated: " . get_class($model) . " #{$model->id}"); }); // Log deletes $dispatcher->attach(Deleting::class, function (Deleting $event) { $model = $event->model; error_log("Deleting: " . get_class($model) . " #{$model->id}"); }); // Now all model operations will be logged $user = new User(['name' => 'John', 'email' => 'john@example.com']); $user->save(); // Logs: Saving, Created, Saved $user->name = 'Jane'; $user->save(); // Logs: Saving, Updating, Updated, Saved $user->delete(); // Logs: Deleting, Deleted
Using with Dependency Injection:
use Psr\EventDispatcher\EventDispatcherInterface; // In your service container or bootstrap Model::dispatcher(function () use ($container) { return $container->get(EventDispatcherInterface::class); });
Advanced Filtering
Datum supports all databoss filter syntax:
// Comparison operators User::where(['age{>}' => 18])->get(); User::where(['price{<=}' => 100])->get(); User::where(['status{!}' => 'inactive'])->get(); // LIKE User::where(['name{~}' => '%John%'])->get(); // IN clause User::where(['category' => ['electronics', 'books']])->get(); // NULL handling User::where(['deleted_at' => null])->get(); User::where(['deleted_at{!}' => null])->get(); // Nested conditions User::where([ 'age{>}' => 18, 'OR' => [ 'status' => 'active', 'verified' => true, ], ])->get();
API Reference
Model Static Properties
protected static ?string $table- The table name (auto-inferred from class name if not set)protected static string $primaryKey- The primary key column name (default:'id')protected static bool $incrementing- Indicates if the IDs are auto-incrementing (default:true). Set tofalsefor UUID primary keysprotected static array $casts- Attribute casting configurationprotected static bool $timestamps- Enable/disable automatic timestamp management (default:true)protected static string $createdAt- The name of the "created at" column (default:'created_at')protected static string $updatedAt- The name of the "updated at" column (default:'updated_at')
Model Static Clock Methods
Model::clock(ClockInterface $clock)- Set a PSR-20 clock instance for timestamp generation
Model Static Event Dispatcher Methods
Model::dispatcher(EventDispatcherInterface|callable|null $dispatcherOrFactory)- Set the PSR-14 event dispatcher instance, factory, ornullto clear
Model Static Methods
Model::connect(ConnectionInterface|callable(): ConnectionInterface $connectionOrFactory)- Set the database connection directly or use a factory for lazy connection creationModel::query()- Create a new query builder instanceModel::where(array $conditions)- Create a query with WHERE conditionsModel::find(int|string $id)- Find a model by primary key (returnsnullif not found)Model::findOrFail(int|string $id)- Find a model or throwRuntimeExceptionif not foundModel::all()- Get all models from the tableModel::first()- Execute the query and return the first model
Model Instance Methods
$model->save()- Save the model to database (inserts if new, updates if exists)$model->delete()- Delete the model from database$model->exists()- Check if model exists in database$model->key()- Get the primary key value$model->toArray()- Convert model to array (with casts applied)$model->attribute(string $key)- Get an attribute value (with casting)$model->assign(string $key, mixed $value)- Set an attribute value (with casting)$model->attributes()- Get all attributes as array (raw, without casting)$model->freshTimestamp()- Get a fresh timestamp string (used internally for automatic timestamps)
Builder Methods
where(array $conditions)- Add WHERE conditions (supports databoss filter syntax)sort(string $column, string $direction = 'ASC')- Add ORDER BY clauselimit(int $limit)- Set LIMIT clauseoffset(int $offset)- Set OFFSET clauseget()- Execute and return all results (returnsarray|false)first()- Execute and return first result (returnsobject|array|false)count()- Count matching records (returnsint|false)exists()- Check if any records exist (returnsbool)recreate()- Get a fresh instance of the query builder
Relationship Methods
one(string $related, string $foreignKey, string $localKey = 'id')- Define has one relationshipmany(string $related, string $foreignKey, string $localKey = 'id')- Define has many relationshipowner(string $related, string $foreignKey, string $ownerKey = 'id')- Define belongs to relationship (this model is owned by another)owners(string $related, string $pivotTable, string $foreignPivotKey, string $relatedPivotKey, string $parentKey = 'id', string $relatedKey = 'id')- Define belongs to many relationship (this model is owned by many)
Property Access
Models support magic property access for attributes and relationships:
$user = User::find(1); $user->name; // Attribute access (with casting) $user->profile; // Relationship access (lazy loaded) $user->name = 'New'; // Attribute assignment (with casting) isset($user->name); // Check if attribute exists
Examples
Complete Example
<?php use Databoss\Connection; use Datum\Model; // Setup connection $connection = new Connection([ Connection::OPT_DATABASE => 'mydb', Connection::OPT_USERNAME => 'root', Connection::OPT_PASSWORD => 'password', ]); Model::connect($connection); // Define model class User extends Model { protected static ?string $table = 'users'; protected static array $casts = [ 'age' => 'int', 'created_at' => 'datetime', 'metadata' => 'array', ]; public function profile() { return $this->one(Profile::class, 'user_id'); } public function posts() { return $this->many(Post::class, 'user_id'); } } // Create $user = new User([ 'name' => 'John Doe', 'email' => 'john@example.com', 'age' => 30, 'created_at' => new DateTime(), 'metadata' => ['role' => 'admin'], ]); $user->save(); // Query $users = User::where(['age{>}' => 25]) ->sort('created_at', 'DESC') ->limit(10) ->get(); // Relationships $profile = $user->profile; $posts = $user->posts;
Testing
The project includes Docker Compose configuration for running tests:
# Start database containers (MySQL, PostgreSQL, and SQL Server) docker compose up -d # Wait for databases to be ready, then run tests ./vendor/bin/phpunit # Run tests with coverage XDEBUG_MODE=coverage ./vendor/bin/phpunit --coverage-text # Stop database containers docker compose down
Tests run against MySQL, PostgreSQL, SQLite, and SQL Server to ensure compatibility across all supported databases.
The test suite includes:
- 163+ tests
- 426+ assertions
- 90%+ code coverage
- Tests for all CRUD operations
- Tests for all relationship types
- Tests for attribute casting
- Tests for query builder methods
- Edge case and error handling tests
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
License
MIT License - see LICENSE file for details.