ancalagon/glaurlink

A lightweight, zero-dependency ORM for PHP 8.4+ and MariaDB/MySQL with type-safe models, enum support, and built-in migrations

Maintainers

Package info

github.com/bmairlot/glaurlink

pkg:composer/ancalagon/glaurlink

Statistics

Installs: 182

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

0.2.1 2026-05-01 19:03 UTC

This package is auto-updated.

Last update: 2026-05-01 19:06:27 UTC


README

A lightweight, zero-dependency ORM for PHP 8.4+ and MariaDB/MySQL.

Features

  • Minimal Dependencies — Only requires PHP core extensions (mysqli, ctype)
  • Type-Safe Models — Automatic type validation using PHP reflection
  • Enum Support — Native PHP backed enum integration for database columns
  • Simple CRUD — Intuitive find(), save(), insert(), collection(), and count() methods
  • Search & Pagination — Built-in support for LIKE queries, ordering, and pagination
  • Collections — Type-safe, iterable collections implementing Iterator, ArrayAccess, and Countable
  • JSON Serialization — Models and collections are JSON-serializable out of the box
  • Lightweight Migrations — File-based migrations with transaction support and rollback capability

Requirements

  • PHP 8.4 or higher
  • mysqli extension
  • ctype extension
  • MariaDB or MySQL database

Installation

composer require ancalagon/glaurlink

Quick Start

Defining a Model

Create a model by extending the base Model class:

<?php

use Ancalagon\Glaurlink\Model;

class User extends Model
{
    protected static string $table = 'users';
    protected static array $fillable = ['name', 'email', 'is_active'];

    public ?int $id = null;
    public string $name;
    public string $email;
    public bool $is_active = false;
}

Basic Operations

<?php

$dbh = new mysqli('localhost', 'user', 'password', 'database');

// Create and save a new record
$user = new User([
    'name' => 'John Doe',
    'email' => 'john@example.com'
]);
$user->save($dbh);

// Find a single record
$user = User::find($dbh, ['id' => 1]);
$user = User::find($dbh, ['email' => 'john@example.com']);

// Update a record
$user->name = 'Jane Doe';
$user->save($dbh);

// Get a collection of records
$activeUsers = User::collection($dbh, conditions: ['is_active' => true]);

// Count records
$count = User::count($dbh, ['is_active' => true]);

Working with Collections

The collection() method returns a Collection object with full iteration support:

<?php

// Fetch with conditions, ordering, and pagination
$users = User::collection(
    $dbh,
    conditions: ['is_active' => true],
    orderBy: ['name' => 'ASC'],
    limit: 10,
    offset: 0
);

// Search across multiple columns
$users = User::collection(
    $dbh,
    searchTerm: 'john',
    searchColumns: ['name', 'email']
);

// Iterate over results
foreach ($users as $user) {
    echo $user->name . "\n";
}

// Array-like access
$firstUser = $users[0];
$totalCount = count($users);

// JSON serialization
echo json_encode($users);

Using Enums

Glaurlink supports PHP backed enums for type-safe column values:

<?php

enum UserStatus: string
{
    case Active = 'active';
    case Inactive = 'inactive';
    case Pending = 'pending';
}

class User extends Model
{
    protected static string $table = 'users';

    public ?int $id = null;
    public string $name;
    public UserStatus $status = UserStatus::Pending;
}

// Enums are automatically converted when reading from/writing to the database
$user = new User(['name' => 'John', 'status' => UserStatus::Active]);
$user->save($dbh);

// Find by enum value
$activeUsers = User::collection($dbh, conditions: ['status' => UserStatus::Active]);

Migrations

Glaurlink includes a lightweight migration system for managing database schema changes.

Migration File Location

By default, migrations are loaded from database/migrations relative to your project root. You can customize this in your composer.json:

{
    "extra": {
        "glaurlink": {
            "migrations_path": "database/migrations"
        }
    }
}

Structure for the Migrations Table

Use the following query to create the glaurlink_migrations table:

CREATE TABLE `glaurlink_migrations` (
    `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
    `name` varchar(255) NOT NULL,
    `batch` int(11) NOT NULL,
    `applied_at` timestamp NOT NULL DEFAULT current_timestamp(),
    PRIMARY KEY (`id`),
    UNIQUE KEY `uniq_name` (`name`)
) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci

Creating a Migration

Create a PHP file in your migrations directory. Files are applied in lexicographical order, so prefix with a timestamp:

database/migrations/20251229120000_create_users_table.php

<?php

return [
    'up' => [
        "CREATE TABLE users (
            id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
            name VARCHAR(255) NOT NULL,
            email VARCHAR(255) NULL,
            status ENUM('active', 'inactive', 'pending') NOT NULL DEFAULT 'pending',
            is_active TINYINT(1) NOT NULL DEFAULT 0,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;",
    ],
    'down' => [
        "DROP TABLE IF EXISTS users;",
    ],
];

Running Migrations

<?php

use Ancalagon\Glaurlink\Migration;

$dbh = new mysqli('localhost', 'user', 'password', 'database');

// Apply all pending migrations
Migration::migrate($dbh);

// Apply from a specific directory
Migration::migrate($dbh, __DIR__ . '/database/migrations');

// Roll back the last batch
Migration::rollback($dbh);

// Roll back multiple batches
Migration::rollback($dbh, steps: 2);

Migration Behavior

  • Each migration runs within a transaction — on error, changes are rolled back
  • Applied migrations are tracked in a glaurlink_migrations table
  • Migrations applied together share the same batch number
  • Rollbacks are performed in reverse order by batch
  • Optionally, applied migration files can be moved to an applied/ subdirectory

DB-Managed Columns

Many columns are NOT NULL in MariaDB but filled by the server: AUTO_INCREMENT primary keys, DEFAULT CURRENT_TIMESTAMP, ON UPDATE CURRENT_TIMESTAMP, DEFAULT UUID(), generated columns, etc. Glaurlink supports these via the $generated property and opt-in rehydration.

The $generated property

Declare columns whose values may be produced by the database:

class User extends Model
{
    protected static string $table     = 'users';
    protected static array  $fillable  = ['name', 'email'];
    protected static array  $generated = ['created_at', 'updated_at'];

    public ?int $id = null;
    public string $name;
    public string $email;
    public ?string $created_at = null;  // DB fills via DEFAULT CURRENT_TIMESTAMP
    public ?string $updated_at = null;  // DB fills via ON UPDATE CURRENT_TIMESTAMP
}

When a $generated column is null at save time, it is omitted from the INSERT/UPDATE column list so MariaDB can apply its default, trigger, or generation rule. If the property has an explicit non-null value, it is emitted verbatim — developer intent always wins.

MariaDB strict mode: DEFAULT and ON UPDATE rules fire only when the column is absent from the statement. Sending NULL to a NOT NULL column in strict mode is an error. That's why Glaurlink omits the column entirely rather than sending NULL.

Rehydration

By default, save() and insert() issue no extra queries. Pass rehydrate: true to re-read the row from the database after the write, populating server-computed values:

$user = new User(['name' => 'Jane', 'email' => 'jane@example.com']);
$user->save($dbh);                  // created_at omitted from SQL; $user->created_at still null in PHP
$user->save($dbh, rehydrate: true); // after the write, $user->created_at holds the DB value

// To let ON UPDATE CURRENT_TIMESTAMP fire on a subsequent update:
$user->name       = 'Jane Doe';
$user->updated_at = null;          // signal "let the DB refresh this"
$user->save($dbh, rehydrate: true);

Uninitialized properties

Non-nullable typed properties without a PHP default (e.g. public string $name;) are legal. They remain uninitialized until set. Uninitialized properties are automatically omitted from INSERT/UPDATE SQL. Accessing them before assignment raises PHP's standard Error: must not be accessed before initialization.

API Reference

Model Methods

Method Description
__construct(array $attributes = []) Create a new model instance
static create(array $attributes = []) Factory method to create a new instance
fill(array $attributes) Mass-assign attributes
save(mysqli $dbh, bool $rehydrate = false) Insert or update the record
insert(mysqli $dbh, bool $rehydrate = false) Explicitly insert a new record
static find(mysqli $dbh, array $attributes) Find a single record by conditions
static collection(mysqli $dbh, ...) Fetch multiple records with filtering
static count(mysqli $dbh, array $conditions = []) Count matching records
jsonSerialize() Get array representation for JSON encoding

Collection Methods

Method Description
count() Get the number of items
toArray() Convert to a plain array
jsonSerialize() Get array representation for JSON encoding
Array access $collection[0], isset($collection[0])
Iteration foreach ($collection as $item)

Migration Methods

Method Description
static migrate(mysqli $dbh, ...) Apply pending migrations
static rollback(mysqli $dbh, ...) Roll back migrations by batch

License

MIT License — see LICENSE for details.

Author

Bruno Mairlot