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

1.2.0 2026-01-02 13:57 UTC

This package is auto-updated.

Last update: 2026-01-02 13:57:33 UTC


README

Tests PHP Version License Code Coverage

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), and owners (belongs to many) relationships
  • Attribute Casting: Automatic type conversion for DateTime, arrays, JSON, integers, floats, and booleans
  • Automatic Timestamps: Automatically manages created_at and updated_at timestamps (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, or ext-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_at and updated_at are automatically set to the current timestamp
  • On Update: Only updated_at is 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:

  • int or integer - Casts to integer
  • float or double - Casts to float
  • bool or boolean - Casts to boolean (stored as 0/1 in database)
  • string - Casts to string
  • array or json - Automatically JSON encodes/decodes
  • datetime or date - Casts to/from DateTime objects

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 model
  • Datum\Events\Created - Fired after creating a new model
  • Datum\Events\Updating - Fired before updating an existing model
  • Datum\Events\Updated - Fired after updating an existing model
  • Datum\Events\Deleting - Fired before deleting a model
  • Datum\Events\Deleted - Fired after deleting a model

Event Order:

When saving a new model:

  1. Saving event
  2. Creating event
  3. Database insert
  4. Created event
  5. Saved event

When updating an existing model:

  1. Saving event
  2. Updating event
  3. Database update
  4. Updated event
  5. Saved event

When deleting a model:

  1. Deleting event
  2. Database delete
  3. Deleted event

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 to false for UUID primary keys
  • protected static array $casts - Attribute casting configuration
  • protected 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, or null to clear

Model Static Methods

  • Model::connect(ConnectionInterface|callable(): ConnectionInterface $connectionOrFactory) - Set the database connection directly or use a factory for lazy connection creation
  • Model::query() - Create a new query builder instance
  • Model::where(array $conditions) - Create a query with WHERE conditions
  • Model::find(int|string $id) - Find a model by primary key (returns null if not found)
  • Model::findOrFail(int|string $id) - Find a model or throw RuntimeException if not found
  • Model::all() - Get all models from the table
  • Model::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 clause
  • limit(int $limit) - Set LIMIT clause
  • offset(int $offset) - Set OFFSET clause
  • get() - Execute and return all results (returns array|false)
  • first() - Execute and return first result (returns object|array|false)
  • count() - Count matching records (returns int|false)
  • exists() - Check if any records exist (returns bool)
  • recreate() - Get a fresh instance of the query builder

Relationship Methods

  • one(string $related, string $foreignKey, string $localKey = 'id') - Define has one relationship
  • many(string $related, string $foreignKey, string $localKey = 'id') - Define has many relationship
  • owner(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.