meritum/database#
meritum/database provides a model and repository layer built on georgeff/database. Models hold typed, cast-aware attributes; repositories handle persistence and querying against a DatabaseManagerInterface.
composer require meritum/databaseModule Registration#
Add DatabaseModule to your module repository:
use Meritum\Database\DatabaseModule;
final class ModuleRepository implements ModuleRepositoryInterface
{
public function modules(Environment $env): array
{
return [
new DatabaseModule(),
// ...
];
}
}The module reads connection configuration from environment variables and registers DriverInterface, ConnectionManagerInterface, and DatabaseManagerInterface in the container. Inject DatabaseManagerInterface into your repositories.
Configuration#
Set these environment variables before boot:
PostgreSQL#
DB_DRIVER=pgsql
DB_HOST=localhost
DB_PORT=5432
DB_DATABASE=myapp
DB_USERNAME=app
DB_PASSWORD=secret
DB_PGSQL_SCHEMA=public
DB_PGSQL_SSL_MODE=preferMySQL#
DB_DRIVER=mysql
DB_HOST=localhost
DB_PORT=3306
DB_DATABASE=myapp
DB_USERNAME=app
DB_PASSWORD=secret
DB_MYSQL_CHARSET=utf8mb4SQLite#
DB_DRIVER=sqlite
DB_DATABASE=/path/to/database.sqliteRead replicas#
DB_READ_HOSTS accepts a JSON array of read replica hostnames (e.g. ["replica1.db", "replica2.db"]). When set, select queries are routed to a read replica. DB_STICKY_WRITE=true (default) routes reads to the write connection for the remainder of the request after any write, avoiding read-after-write lag from replication delay.
Defining a Model#
Extend Model and define your attributes as PHP 8.4 property hooks backed by getAttribute() and setAttribute():
use Meritum\Database\Model;
final class User extends Model
{
protected string $table = 'users';
protected array $casts = [
'email_verified_at' => 'datetime',
'is_active' => 'bool',
];
public string $id {
get => $this->getAttribute('id');
}
public string $name {
get => $this->getAttribute('name');
set => $this->setAttribute('name', $value);
}
public string $email {
get => $this->getAttribute('email');
set => $this->setAttribute('email', $value);
}
public ?\DateTimeImmutable $emailVerifiedAt {
get => $this->getAttribute('email_verified_at');
}
public bool $isActive {
get => $this->getAttribute('is_active');
set => $this->setAttribute('is_active', $value);
}
}$table must be set. $primaryKey defaults to 'id'. Timestamps (created_at / updated_at) are managed automatically unless you set $timestamps = false.
Primary Keys#
By default models use string (UUID v7) primary keys. The repository auto-generates a UUID when saving a new model with no primary key value set.
// String UUID (default) — $primaryKeyType = 'string', $incrementing = false
protected string $primaryKey = 'id';
protected string $primaryKeyType = 'string';
protected bool $incrementing = false;For auto-increment integer primary keys:
protected string $primaryKey = 'id';
protected string $primaryKeyType = 'int';
protected bool $incrementing = true;UUID Versions#
Meritum\Database\Support\Uuid provides two generators:
| Method | Version | Ordering |
|---|---|---|
Uuid::v4() |
UUIDv4 | Random — no natural ordering |
Uuid::v7() |
UUIDv7 | Time-ordered — millisecond timestamp in the first 48 bits |
Repository::generateUuid() defaults to Uuid::v7(). Override it in your repository to change the generator:
use Meritum\Database\Support\Uuid;
protected function generateUuid(): string
{
return Uuid::v4();
}UUIDv7 (the default) is required for models used with primary-key cursor pagination — see Cursor Pagination below.
Attribute Casts#
Declare casts in $casts to convert between PHP types and database storage:
protected array $casts = [
'age' => 'int',
'score' => 'float',
'is_active' => 'bool',
'metadata' => 'json',
'created_at' => 'datetime',
'birthday' => 'date',
'expires_at' => 'timestamp',
];| Cast | PHP type on read | Stored as |
|---|---|---|
int |
int |
int |
float |
float |
float |
string |
string |
string |
bool |
bool |
bool |
json |
array (decoded) |
JSON string |
datetime |
DateTimeImmutable (with time) |
Y-m-d H:i:s string |
date |
DateTimeImmutable (midnight) |
Y-m-d string |
timestamp |
DateTimeImmutable |
Unix timestamp integer |
toArray() formats DateTimeImmutable values as ISO 8601 strings.
Accessors and Mutators#
Override accessors() and mutators() to apply transformations on read and write:
protected function accessors(): array
{
return [
'name' => fn(string $value) => ucwords($value),
];
}
protected function mutators(): array
{
return [
'email' => fn(string $value) => strtolower($value),
];
}Accessors run after casting on read. Mutators run before casting on write.
Relations#
Models carry a protected relation bag for attaching related data that has been loaded separately. There is no lazy loading — the caller fetches related records independently and attaches them to the model.
Expose relations through typed public wrapper methods on the concrete model:
final class EventLog extends Model
{
protected string $table = 'event_logs';
public function setEvent(Event $event): void
{
$this->setRelation('event', $event);
}
public function getEvent(): Event
{
/** @var Event */
return $this->getRelation('event');
}
public function hasEvent(): bool
{
return $this->hasRelation('event');
}
}Attach the relation in a handler after running both queries independently:
$log = $logRepository->findOrFail($id);
$event = $eventRepository->findOrFail($log->eventId);
$log->setEvent($event);Relations are merged into toArray() automatically. The key passed to setRelation() becomes the key in the serialized output:
{
"id": "...",
"event_id": "...",
"event": { "id": "...", "name": "PHP Conference" }
}The relation bag accepts any JSON-safe value — a model, a collection, a paginator, a scalar, or an array. Object values must implement JsonSerializable; passing anything else throws a LogicException. The typed wrapper method on the concrete model is the actual type contract — the base bag is intentionally untyped so it does not restrict what sources or shapes of data can be attached.
Defining a Repository#
Define an interface for your repository that extends RepositoryInterface, then implement it by extending Repository:
use Meritum\Database\RepositoryInterface;
interface UserRepositoryInterface extends RepositoryInterface
{
public function findByEmail(string $email): ?User;
}use Meritum\Database\Repository;
use Georgeff\Database\Contract\DatabaseManagerInterface;
final class UserRepository extends Repository implements UserRepositoryInterface
{
protected function getModelClass(): string
{
return User::class;
}
public function findByEmail(string $email): ?User
{
$this->query()->where('email', $email);
return $this->first();
}
}Register it in a module bound to your own interface:
$kernel->define(UserRepositoryInterface::class, fn($c) => new UserRepository(
$c->get(DatabaseManagerInterface::class)
));Consuming classes type-hint UserRepositoryInterface, not the concrete class.
Built-in Finders#
These methods are available on every repository:
// Save (insert or update)
$user = new User();
$user->name = 'Jane Smith';
$user->email = 'jane@example.com';
$repository->save($user); // returns bool
// Delete
$repository->delete($user); // returns bool
// Find by primary key
$user = $repository->find('uuid-here'); // returns ?User
$user = $repository->findOrFail('uuid-here'); // returns User or throws ModelNotFoundException
// Find by any column
$user = $repository->findBy('email', 'jane@example.com'); // returns ?User
save() inserts when $model->isNew() and updates otherwise. Timestamps and UUID generation are automatic.
Custom Queries#
Use the protected query() method to build a query, then execute it with first(), firstOrFail(), get(), or count(). query() returns a SelectInterface from georgeff/database; the execute methods are on the repository itself:
public function findByEmail(string $email): ?User
{
$this->query()->where('email', $email);
return $this->first();
}
public function findBySlug(string $slug): Post
{
$this->query()->where('slug', $slug);
return $this->firstOrFail();
}
public function findActive(): Collection
{
$this->query()->where('is_active', true)->orderBy('name', 'ASC');
return $this->get();
}
public function countActive(): int
{
$this->query()->where('is_active', true);
return $this->count();
}firstOrFail() throws ModelNotFoundException when the query returns no result.
query() resets the internal query state each time it is called — always call it at the start of a new query chain.
Scopes#
Register reusable query constraints with addScope() to apply them automatically on every query:
public function __construct(DatabaseManagerInterface $db)
{
parent::__construct($db);
$this->addScope('active', function (SelectInterface $query): void {
$query->where('is_active', true);
});
}Bypass scopes when needed:
// Bypass a specific scope
$this->withoutScope('active')->query()-> ...
// Bypass all scopes
$this->withoutScopes()->query()-> ...Scope bypasses are reset after each query execution.
Pagination#
Offset pagination#
public function listByDate(int $page, int $perPage): Paginator
{
$this->query()->orderBy('created_at', 'DESC');
return $this->paginate($perPage, $page);
}Paginator carries the collection plus total, perPage, currentPage, lastPage, from, and to.
Cursor pagination#
public function cursorPaginate(int $perPage, ?string $cursor = null): CursorPaginator
{
$this->query()->where('is_active', true);
return $this->cursor($perPage, $cursor);
}Cursor pagination uses the primary key for ordering and keyset-based pagination. CursorPaginator carries the collection plus opaque nextCursor and previousCursor tokens. Pass the next/previous token back in the following request.
cursor() calls resetOrderBy() before applying its own ORDER BY, so a prior orderBy() call on the query — including one applied by a scope — is safely cleared rather than conflicting with it.
**Limitation** UUIDv4 primary keys are randomly generated and have no natural ordering, making them unsuitable as a cursor column. String primary keys default to UUIDv7, which sorts correctly — this only matters if a model overrides `generateUuid()` to return `Uuid::v4()`. Sort by a different column with `cursorBy()` instead.
Sorting by a custom column#
cursorBy(string $column, int $perPage, ?string $cursor = null) cursor-paginates by any column instead of the primary key:
public function cursorPaginateByCreatedAt(int $perPage, ?string $cursor = null): CursorPaginator
{
$this->query()->where('is_active', true);
return $this->cursorBy('created_at', $perPage, $cursor);
}The primary key is always included as a tiebreaker — sorted and compared alongside $column — so pagination stays correct even when $column has duplicate values. cursor() is implemented as cursorBy($primaryKeyName, ...), so both share the same tiebreaking behavior.
Collection#
get() and the paginator’s collection() method return a typed Collection<T>. See the Collection API page
for the full method list.
$users = $repository->findActive();
foreach ($users as $user) {
echo $user->name;
}
$admins = $users->filter(fn(User $u) => $u->role === 'admin');Caching#
Model and Collection both support PHP native serialization, making them compatible with any cache backend that uses serialize() (PSR-6, PSR-16, Redis, APCu). The unserialized model has no dirty state and is safe to read from immediately.
$user = $repository->findOrFail($id);
$cache->set("user:{$id}", serialize($user));
$user = unserialize($cache->get("user:{$id}"));