memran/marwa-db

Lightweight, framework-agnostic PHP database toolkit: connections, query builder, ORM, schema, migrations, seeders, debug panel.

Maintainers

Package info

github.com/memran/marwa-db

pkg:composer/memran/marwa-db

Transparency log

Statistics

Installs: 1 030

Dependents: 1

Suggesters: 1

Stars: 5

Open Issues: 0

v1.2.8 2026-08-10 15:28 UTC

This package is auto-updated.

Last update: 2026-08-10 16:13:32 UTC


README

Latest Version Total Downloads License PHP Version CI PHPStan

memran/marwa-db is a framework-agnostic PHP database toolkit built on PDO. It provides:

  • connection management with pooling and retry support
  • a fluent query builder
  • an Active Record style ORM
  • schema and migration helpers
  • seeder discovery and execution
  • query logging, a built-in debug panel, and optional memran/marwa-debugbar integration

The package is intended for plain PHP applications, small frameworks, and custom stacks that want database tooling without a full framework dependency.

Requirements

  • PHP 8.2+
  • ext-pdo
  • ext-json
  • a supported PDO driver: MySQL, PostgreSQL, or SQLite

Installation

Install the package:

composer require memran/marwa-db

Optional development debug bar:

composer require --dev memran/marwa-debugbar

For work inside this repository:

composer install

Package Overview

Primary entry points:

  • Marwa\DB\Bootstrap
  • Marwa\DB\Connection\ConnectionManager
  • Marwa\DB\Facades\DB
  • Marwa\DB\Query\Builder
  • Marwa\DB\ORM\Model
  • Marwa\DB\Schema\Schema
  • Marwa\DB\Seeder\SeedRunner

Quick Start

<?php

require __DIR__ . '/vendor/autoload.php';

use Marwa\DB\Bootstrap;
use Marwa\DB\Facades\DB;
use Marwa\DB\ORM\Model;
use Marwa\DB\Schema\Schema;

$config = [
    'default' => [
        'driver' => 'sqlite',
        'database' => __DIR__ . '/database.sqlite',
        'debug' => true,
    ],
];

$manager = Bootstrap::init($config, enableDebugPanel: true);

DB::setManager($manager);
Model::setConnectionManager($manager);
Schema::init($manager);

At this point you can:

  • build queries through DB::table(...)
  • configure models with Model::setConnectionManager(...)
  • run schema operations with Schema::create(...) and Schema::drop(...)
  • render debugging output with echo $manager->renderDebugBar() when memran/marwa-debugbar is installed

Configuration

The package expects a named connection array. The simplest configuration looks like this:

return [
    'default' => [
        'driver' => 'mysql',
        'host' => '127.0.0.1',
        'port' => 3306,
        'database' => 'app',
        'username' => 'root',
        'password' => '',
        'charset' => 'utf8mb4',
        'options' => [],
        'debug' => false,
    ],
];

SQLite example:

return [
    'default' => [
        'driver' => 'sqlite',
        'database' => __DIR__ . '/../database/app.sqlite',
        'debug' => true,
    ],
];

Supported top-level connection fields:

  • driver
  • host
  • port
  • database
  • username
  • password
  • charset
  • options
  • debug

Bootstrap and Connection Management

Bootstrap::init(array $dbConfig, ?LoggerInterface $logger = null, bool $enableDebugPanel = false): ConnectionManager

Creates the ConnectionManager, optionally enables debugging helpers, and stores the manager globally in $GLOBALS['cm'].

use Marwa\DB\Bootstrap;

$manager = Bootstrap::init($config, enableDebugPanel: true);

When enableDebugPanel is true:

  • the built-in DebugPanel is attached
  • QueryLogger is attached
  • memran/marwa-debugbar is attached automatically if installed

ConnectionManager

Common public methods:

  • getPdo(?string $name = 'default'): PDO
  • getConnection(?string $name = 'default'): PDO
  • transaction(callable $callback, ?string $connectionName = null): mixed
  • beginTransaction(?string $connectionName = null): void
  • commit(?string $connectionName = null): void
  • rollBack(?string $connectionName = null): void
  • transactionLevel(?string $connectionName = null): int
  • setDebugPanel(?DebugPanel $panel): void
  • getDebugPanel(): ?DebugPanel
  • setDebugBar(?object $debugBar): void
  • getDebugBar(): ?object
  • renderDebugBar(): string
  • setQueryLogger(?QueryLogger $queryLogger): void
  • getQueryLogger(): ?QueryLogger
  • isDebug(string $name = 'default'): bool
  • getDriver(string $name = 'default'): string
  • pickReplica(array $replicas): PDO

Example:

$pdo = $manager->getPdo();

$manager->transaction(function (PDO $connection): void {
    $connection->exec("INSERT INTO users (name) VALUES ('Alice')");
});

Nested transactions use database savepoints. A failed inner transaction rolls back to its savepoint without discarding successful work in the outer transaction.

Global Query Instrumentation

Query logging is captured below the query builder layer. Any SQL executed through the PDO returned by ConnectionManager::getPdo() is loggable:

  • query builder statements
  • ORM/model statements
  • raw PDO::query(...)
  • raw PDO::exec(...)
  • prepared statements created through PDO::prepare(...)->execute(...)

This is package-level behavior. Application code does not need a separate wrapper around raw PDO usage.

Query Builder

The query builder is available through DB::table(...) or by instantiating Marwa\DB\Query\Builder directly.

DB::setManager(ConnectionManager $cm): void

Registers the shared manager used by the facade.

DB::table(string $table, string $conn = 'default'): Builder

Starts a fluent query against a table.

Transaction methods

  • DB::connection(?string $name = null): ConnectionManager
  • DB::beginTransaction(string $conn = 'default'): void
  • DB::commit(string $conn = 'default'): void
  • DB::rollback(string $conn = 'default'): void
  • DB::transaction(callable $callback, string $conn = 'default'): mixed

Example:

$result = DB::transaction(function () {
    User::create(['name' => 'Alice']);
    Order::create(['user_id' => 1, 'total' => 100]);
});
use Marwa\DB\Facades\DB;

DB::setManager($manager);

$users = DB::table('users')
    ->select('id', 'email')
    ->where('status', '=', 'active')
    ->orderBy('id', 'desc')
    ->limit(10)
    ->get();

Query builder methods

Selection and table:

  • table(string $table): self
  • from(string $table): self
  • select(string ...$columns): self
  • selectRaw(string $expression, array $bindings = []): self

Filtering and ordering:

  • when(mixed $value, callable $callback, ?callable $default = null): self
  • unless(mixed $value, callable $callback, ?callable $default = null): self
  • where(string $column, string $operator, mixed $value, string $boolean = 'and'): self
  • orWhere(string $column, string $operator, mixed $value): self
  • whereKey(int|string|array $id): self
  • whereIn(string $column, array $values, bool $not = false, string $boolean = 'and'): self
  • whereNotIn(string $column, array $values, string $boolean = 'and'): self
  • whereNull(string $column, string $boolean = 'and'): self
  • whereNotNull(string $column, string $boolean = 'and'): self
  • whereJsonContains(string $column, mixed $value): self
  • whereJsonLength(string $column, int $length): self
  • whereJsonValue(string $column, string $path, mixed $value): self
  • orderBy(string $column, string $direction = 'asc'): self
  • limit(int $n): self
  • offset(int $n): self

Reading:

  • get(int $fetchMode = PDO::FETCH_ASSOC): array
  • first(int $fetchMode = PDO::FETCH_ASSOC): array|object|null
  • value(string $column): mixed
  • pluck(string $column): Collection
  • count(string $column = '*'): int
  • max(string $column): mixed
  • min(string $column): mixed
  • sum(string $column): int|float|null
  • avg(string $column): ?float
  • paginate(int $perPage = 15, int $page = 1, int $fetchMode = PDO::FETCH_ASSOC): array
  • withCount(string ...$relations): self

Writing:

  • insert(array $data): int - insert one row or a list of rows
  • insertOrIgnore(array $data): int
  • upsert(array $data, array|string $uniqueBy, ?array $update = null): int
  • insertGetId(array $data): int|string
  • update(array $data): int
  • delete(): int
  • increment(string $column, int|float $amount = 1): int
  • decrement(string $column, int|float $amount = 1): int

Concurrency:

  • lockForUpdate(): self
  • sharedLock(): self

Row locks must be used inside a transaction and are supported by MySQL and PostgreSQL. SQLite throws a LogicException because it does not provide row-level locking clauses.

Debugging helpers:

  • toSql(): string
  • getBindings(): array
  • clear(): void

Example:

$total = DB::table('orders')
    ->where('status', '=', 'paid')
    ->sum('amount');

DB::table('users')->upsert(
    ['email' => 'alice@example.com', 'name' => 'Alice Updated'],
    uniqueBy: 'email',
    update: ['name'],
);

More query builder examples:

$recentUsers = DB::table('users')
    ->select('id', 'name', 'email')
    ->when(true, fn ($query) => $query->where('active', '=', 1))
    ->orderBy('id', 'desc')
    ->limit(5)
    ->get();

$paidOrders = DB::table('orders')
    ->where('status', '=', 'paid')
    ->whereBetween('created_at', ['2026-01-01', '2026-12-31'])
    ->groupBy('user_id')
    ->having('COUNT(*)', '>', 1)
    ->get();

ORM

Extend Marwa\DB\ORM\Model to define your models.

use Marwa\DB\ORM\Model;

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

The table name is inferred automatically from the class name (e.g. Userusers, UserProfileuser_profiles). Set $table explicitly to override.

Register the connection manager once:

User::setConnectionManager($manager);

Switch connection per-query:

User::on('mysql_secondary')->where('active', 1)->get();

Practical ORM examples:

// Fetch a single record
$user = User::find(1);

// Compose scopes with the ORM query builder
$users = User::active()
    ->popular()
    ->with('posts')
    ->orderBy('name')
    ->get();

// Count related records
$users = User::withCount('posts')->get();

// Create and persist a model
$created = User::create([
    'name' => 'Alice',
    'email' => 'alice@example.com',
]);

Model Events

The Observable trait provides event hooks for model lifecycle:

  • Model::creating(callable $callback): void
  • Model::created(callable $callback): void
  • Model::updating(callable $callback): void
  • Model::updated(callable $callback): void
  • Model::saving(callable $callback): void
  • Model::saved(callable $callback): void
  • Model::deleting(callable $callback): void
  • Model::deleted(callable $callback): void
  • Model::restoring(callable $callback): void
  • Model::restored(callable $callback): void

Each method also has an on-prefixed alias (e.g. onCreating, onCreated, etc.).

Example:

User::onCreated(function ($user) {
    Log::info("User created: {$user->id}");
});

Common model API

Setup:

  • setTable(string $table): void
  • setConnectionManager(ConnectionManager $cm, string $connection = 'default'): void
  • table(): string

Query entry points:

  • query(): Marwa\DB\ORM\QueryBuilder
  • newQuery(): Marwa\DB\ORM\QueryBuilder
  • on(string $connection): Marwa\DB\ORM\QueryBuilder
  • where(string $col, mixed $op, mixed $val = null): Marwa\DB\ORM\QueryBuilder
  • whereKey(int|string|array $id): Marwa\DB\ORM\QueryBuilder
  • whereIn(string $col, array $values): Marwa\DB\ORM\QueryBuilder
  • whereNull(string $col): Marwa\DB\ORM\QueryBuilder
  • whereNotNull(string $col): Marwa\DB\ORM\QueryBuilder
  • withCount(string ...$relations): Marwa\DB\ORM\QueryBuilder
  • all(): array
  • find(int|string $id): ?static
  • findOrFail(int|string $id): static
  • first(): ?static
  • firstWhere(string $col, mixed $op, mixed $val = null): ?static
  • firstOrFail(): Model
  • updateOrCreate(array $attributes, array $values = []): static
  • firstOrCreate(array $attributes, array $values = []): static
  • exists(): bool
  • count(string $col = '*'): int
  • paginate(int $perPage = 15, int $page = 1): array
  • chunk(int $size, callable $callback): void
  • chunkById(int $size, callable $callback, string $idCol = 'id'): void

Static calls and query shortcuts resolve to the query builder. For example, User::where(...) and User::orderBy(...) are equivalent to User::query()->where(...):

$users = User::where('active', 1)->orderBy('name')->get();
$user  = User::firstWhere('email', '=', 'a@b.com');

Writes:

  • create(array $attributes): static
  • save(): bool
  • destroy(int|array $ids): int
  • touch(?string $attribute = null): bool

Timestamps:

  • usesTimestamps(): bool
  • createdAt(): ?string
  • updatedAt(): ?string

State and change tracking:

  • isDirty(?string $attribute = null): bool
  • isClean(?string $attribute = null): bool
  • syncOriginal(): static
  • refresh(): static
  • fresh(): ?static

Attribute and serialization helpers:

  • fill(array $attributes): static
  • forceFill(array $attributes): static
  • only(array $keys): array
  • except(array $keys): array
  • getDirty(): array
  • getKey(): int|string|null
  • getKeyName(): string
  • getAttribute(string $key): mixed — applies type casts on read
  • setAttribute(string $key, mixed $value): static
  • hasAttribute(string $key): bool
  • append(array $attributes): static
  • setHidden(array $hidden): static
  • setVisible(array $visible): static
  • replicate(?array $except = null): static
  • fill(array $attributes): static — respects $fillable/$guarded

Type casting:

  • casts(): array
  • castIn(string $key, mixed $value): mixed
  • castOut(string $key, mixed $value): mixed
  • mergeCasts(array $casts): static
  • toArray(): array
  • toJson(int $options = JSON_UNESCAPED_UNICODE): string

Supported cast types: int, float, bool, json. Casts apply lazily on getAttribute() reads and toArray() serialization.

class User extends Model
{
    protected static array $casts = ['is_active' => 'bool', 'meta' => 'json'];
}

$user = User::find(1);
var_dump($user->is_active); // bool, not int

Soft deletes:

  • delete(): bool — marks deleted_at when $softDeletes is true
  • forceDelete(): bool — hard deletes even with soft deletes enabled
  • restore(): bool
  • trashed(): bool
  • withTrashed(): Marwa\DB\ORM\QueryBuilder — includes soft-deleted rows
  • onlyTrashed(): Marwa\DB\ORM\QueryBuilder — only soft-deleted rows
class Post extends Model
{
    protected static bool $softDeletes = true;
}

// Default: excludes soft-deleted
$posts = Post::where('active', 1)->get();

// Include trashed
$all = Post::withTrashed()->where('active', 1)->get();

// Only trashed
$trashed = Post::onlyTrashed()->get();

Scopes:

  • addGlobalScope(Closure $scope, ?string $identifier = null): void
  • withoutGlobalScope(string $identifier): static
  • Local scopes: methods named scopeXxx() are callable as ->xxx(), Model::xxx(), or Model::query()->xxx(), and they return the query builder for chaining
class User extends Model
{
    public function scopeActive($query): void
    {
        $query->where('active', 1);
    }
    public function scopePopular($query): void
    {
        $query->where('votes', '>', 100);
    }
}

// Scopes compose via the query builder chain
$users = User::active()->popular()->orderBy('name')->get();

// Or on an instance
$user->active()->popular()->get();

Relations

Define relationships with shorthand methods on your model:

class User extends Model
{
    public function posts(): HasMany
    {
        return $this->hasMany('user_id');
    }
    public function profile(): HasOne
    {
        return $this->hasOne('user_id');
    }
    public function role(): BelongsTo
    {
        return $this->belongsTo('role_id');
    }
    public function roles(): BelongsToMany
    {
        return $this->belongsToMany(Role::class, 'role_user', 'user_id', 'role_id');
    }
}

The shorthand relation form infers the related model from the method name, so posts() maps to Post::class, profile() maps to Profile::class, and user() maps to User::class when the model lives in the same namespace.

BelongsToMany stays explicit because it needs pivot table and pivot key names in addition to the related model.

Supported relations:

  • HasOne$this->hasOne('foreign_key', 'local_key') or $this->hasOne(Related::class, 'foreign_key', 'local_key')
  • HasMany$this->hasMany('foreign_key', 'local_key') or $this->hasMany(Related::class, 'foreign_key', 'local_key')
  • BelongsTo$this->belongsTo('foreign_key', 'owner_key') or $this->belongsTo(Related::class, 'foreign_key', 'owner_key')
  • BelongsToMany$this->belongsToMany(Related::class, 'pivot_table', 'foreign_pivot_key', 'related_pivot_key', 'parent_key', 'related_key', ['pivot_columns'])
  • MorphTo$this->morphTo('morph_type', 'morph_id')
  • MorphMany$this->morphMany(Related::class, 'morph_type', 'morph_id', 'local_key')

Lazy loading:

foreach ($user->posts as $post) { ... }

Eager loading via with():

$users = User::with('posts', 'profile')->get();
$users = User::with('posts.comments')->get();

Eager loading after retrieval via load() / loadMissing():

$user->load('posts');
$user->loadMissing('comments'); // only loads if not already loaded

Model instance API

$user = User::find(1);

if ($user !== null) {
    $user->fill([
        'email' => 'new@example.com',
    ])->save();
}

ORM Query Builder

Marwa\DB\ORM\QueryBuilder is returned by Model::query() and hydrates records into model instances.

$users = User::where('active', 1)
    ->with('posts', 'profile')
    ->orderBy('name')
    ->limit(10)
    ->get();

Fluent proxy methods:

  • select(string ...$cols): self
  • selectRaw(string $expr, array $bindings = []): self
  • where(callable|string $col, mixed $op = null, mixed $val = null): self
  • orWhere(callable|string $col, mixed $op = null, mixed $val = null): self
  • whereColumn(string $first, mixed $operator, ?string $second = null): self
  • orWhereColumn(...): self
  • whereRaw(string $sql, array $bindings = []): self
  • orWhereRaw(...): self
  • whereIn(string $col, array $values): self
  • whereNotIn(string $col, array $values): self
  • whereNull(string $col): self
  • whereNotNull(string $col): self
  • whereBetween(string $col, array $values): self
  • whereNotBetween(string $col, array $values): self
  • whereExists(callable|Builder $subquery): self
  • whereNotExists(...): self
  • whereJsonContains(string $col, mixed $value): self
  • whereJsonLength(string $col, int $length): self
  • whereJsonValue(string $col, string $path, mixed $value): self
  • whereNested(callable $callback, string $boolean = 'and'): self

Grouping / ordering:

  • groupBy(string ...$cols): self
  • groupByRaw(string $expression): self
  • having(string $col, string $op, mixed $val): self
  • orHaving(string $col, string $op, mixed $val): self
  • havingRaw(string $sql, array $bindings = []): self
  • orderBy(string $col, string $dir = 'asc'): self
  • limit(int $n): self
  • offset(int $n): self

Joins:

  • join(string $table, string $first, string $operator, string $second): self
  • leftJoin(...): self
  • rightJoin(...): self

Reads:

  • get(): array<Model>
  • first(): ?Model
  • firstOrFail(): Model
  • value(string $column): mixed
  • pluck(string $column): Collection
  • count(string $col = '*'): int
  • exists(): bool
  • max(string $col): mixed
  • min(string $col): mixed
  • sum(string $col): int|float|null
  • avg(string $col): ?float
  • paginate(int $perPage = 15, int $page = 1): array
  • chunk(int $size, callable $callback): void
  • chunkById(int $size, callable $callback, string $idCol = 'id'): void

Writes:

  • insert(array $data): int
  • insertGetId(array $data): int|string
  • update(array $data): int
  • delete(): int
  • increment(string $column, int|float $amount = 1): int
  • decrement(string $column, int|float $amount = 1): int

Eager loading:

  • with(string ...$relations): self
User::with('posts', 'profile.comments')->get();

Connection switching:

  • on(string $connection): self
User::on('replica')->where('active', 1)->get();

Scope forwarding: unknown methods are resolved as local scopes on the model, so chaining continues on the query builder:

User::active()->popular()->get();

Utilities:

  • toSql(): string
  • getBindings(): array
  • clear(): void

Schema Builder

The schema layer is centered on Marwa\DB\Schema\Schema and Marwa\DB\Schema\Builder.

Schema::init(?ConnectionManager $cm = null, ?string $connectionName = null): void

Initializes the static schema facade. If $cm is omitted, the package reads $GLOBALS['cm'].

Schema::create(string $table, callable $callback): void

Creates a table.

Schema::drop(string $table): void

Drops a table.

Example:

use Marwa\DB\Schema\Schema;

Schema::init($manager);

Schema::create('posts', static function ($table): void {
    $table->increments('id');
    $table->string('title');
    $table->text('body');
    $table->timestamps();
});

For instance-based use:

  • Builder::useConnectionManager(ConnectionManager $cm): Builder
  • Builder::create(string $table, Closure $callback): void
  • Builder::table(string $table, Closure $callback): void
  • Builder::drop(string $table): void
  • Builder::rename(string $from, string $to): void
  • Builder::hasTable(string $table): bool
  • Builder::hasColumn(string $table, string $column): bool
  • Builder::getForeignKeys(string $table): array
  • Builder::databaseVersion(): string
  • Builder::supportsSchemaOperation(string $operation): bool

Blueprint column helpers

Common schema methods on the table blueprint:

  • increments()
  • bigIncrements()
  • uuid()
  • uuidPrimary()
  • string()
  • text()
  • mediumText()
  • longText()
  • integer()
  • tinyInteger()
  • smallInteger()
  • bigInteger()
  • boolean()
  • decimal()
  • float()
  • double()
  • date()
  • dateTime()
  • timestamp()
  • timestamps()
  • softDeletes()
  • json()
  • jsonb()
  • binary()
  • enum()
  • set()
  • foreignId()
  • primary()
  • unique()
  • index()
  • foreign()
  • dropColumn()
  • dropForeign()
  • dropIndex()
  • dropUnique()
  • renameColumn()
  • renameIndex()
  • dropPrimary()
  • dropDefault()

Blueprint methods:

  • comment(string $comment): self - set table comment

Column modifiers are available through ColumnDefinition, including:

  • nullable()
  • default()
  • unsigned()
  • autoIncrement()
  • comment() - column comment
  • primary()
  • length()
  • comment()
  • unique()
  • index()
  • primaryKey()
  • change()

Drop columns and named foreign-key constraints from a migration with Schema::table():

Schema::table('posts', static function ($table): void {
    // Drop the constraint before dropping its column.
    $table->dropForeign('posts_user_id_foreign');
    $table->dropColumn('user_id');
    $table->dropColumn(['legacy_title', 'legacy_slug']);
});

Additional alteration helpers accept explicit index names:

Schema::table('posts', static function ($table): void {
    $table->dropIndex('idx_posts_status');
    $table->dropUnique('uniq_posts_slug');
    $table->renameColumn('title', 'headline');
    $table->renameIndex('idx_posts_author', 'idx_posts_user');
    $table->string('headline', 300)->nullable()->default('draft')->change();
    $table->dropDefault('published_at');
    $table->dropPrimary();
});

if (Schema::hasTable('posts') && Schema::hasColumn('posts', 'headline')) {
    // The schema is ready for the new application version.
}

$foreignKeys = Schema::getForeignKeys('posts');
$canModify = Schema::supportsSchemaOperation('modifyColumn');
Schema::rename('posts', 'articles');

Unnamed foreign keys receive a deterministic <table>_<columns>_foreign constraint name, such as posts_user_id_foreign. An explicit name passed to foreign() overrides this convention. MySQL uses DROP FOREIGN KEY; PostgreSQL uses DROP CONSTRAINT. SQLite implements dropForeign() with a transactional table rebuild because it has no direct ALTER TABLE syntax for removing constraints. The rebuild preserves data, remaining table definitions, explicit indexes, triggers, views, generated columns, and table options. Named, unnamed, inline, composite, and self-referencing foreign keys are supported. Remaining constraints are validated with PRAGMA foreign_key_check before commit.

SQLite also uses the rebuild path for change(), dropPrimary(), and dropDefault(), and as a dropColumn() fallback before SQLite 3.35. Rebuild operations may be combined with each other but must use a dedicated Schema::table() callback; mixing column additions, index additions, comments, or other direct alterations into that callback is rejected before schema changes are made. A primary key cannot be removed from a WITHOUT ROWID table because SQLite requires such tables to retain a primary key. Adding foreign keys to an existing SQLite table remains unsupported.

SQLite rebuilds cannot run inside an existing transaction or savepoint. SQLite treats PRAGMA foreign_keys changes as no-ops while a transaction is active, so the builder throws a LogicException before making changes. Run rebuild migrations outside application-managed transactions; the builder creates and owns the atomic rebuild transaction itself.

Table comments set with $table->comment('...') are compiled on MySQL and PostgreSQL for both table creation and alteration. SQLite does not support table comments, so using this method with SQLite throws a LogicException. SQLite also does not support renameIndex(); drop and recreate the index instead. Version-sensitive native operations such as SQLite column dropping and MySQL column renaming are checked against the connected database version before execution.

Migrations

Migration helpers are available through the CLI and through Marwa\DB\Schema\MigrationRepository.

Common MigrationRepository methods:

  • ensureTable(): void
  • migrate(): int
  • rollbackLastBatch(): int
  • rollbackAll(): int
  • refresh(): array
  • getRanWithDetails(): array
  • getMigrationFiles(): array

Migration files generated by the package return an anonymous class extending Marwa\DB\CLI\AbstractMigration. Migration execution validates the returned value against Marwa\DB\CLI\MigrationInterface and throws an UnexpectedValueException before calling up() or down() when a file does not return a valid migration.

Migration execution is protected by a deployment lock: MySQL uses GET_LOCK, PostgreSQL uses advisory locks, and SQLite uses an OS file lock. Applied files are stored with SHA-256 checksums and state (running, completed, failed, rolling_back, or rollback_failed). Changed migration files and incomplete states stop further migration work and require explicit operator recovery.

Multiple migration directories may share one database and migrations table. Each MigrationRepository owns and validates its migration files, so module-level migrate, status, rollback, and refresh operations remain isolated. Existing rows from earlier releases are assigned automatically by matching migration filenames; existing two-argument constructor calls require no application changes. Applications may pass a stable repository name as the fourth constructor argument when ownership must remain identifiable even if every migration file is removed:

$repository = new MigrationRepository(
    $pdo,
    __DIR__ . '/database/migrations',
    repository: 'billing-module',
);

Migration filenames must remain unique across all repositories sharing the table.

Example generated structure:

<?php

use Marwa\DB\CLI\AbstractMigration;
use Marwa\DB\Schema\Schema;

return new class extends AbstractMigration {
    public function up(): void
    {
        Schema::create('users', function ($table) {
            $table->increments('id');
            $table->string('name');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::drop('users');
    }
};

Seeders

Seeder execution is handled by Marwa\DB\Seeder\SeedRunner.

SeedRunner

Constructor:

new SeedRunner(
    cm: $manager,
    logger: null,
    connection: 'default',
    seedPath: __DIR__ . '/database/seeders',
    seedNamespace: 'Database\\Seeders',
);

Public methods:

  • runAll(bool $wrapInTransaction = true, ?array $only = null, array $except = []): void
  • runOne(string $fqcn, bool $wrapInTransaction = true): void
  • discoverSeeders(): array

Example:

use Marwa\DB\Seeder\SeedRunner;

$runner = new SeedRunner($manager);
$runner->runAll();

Seeder classes implement Marwa\DB\Seeder\Seeder:

use Marwa\DB\Seeder\Seeder;

final class UsersTableSeeder implements Seeder
{
    public function run(): void
    {
        // seed logic
    }
}

Debugging and Query Inspection

Built-in debug panel

Enable it during bootstrap:

$manager = Bootstrap::init($config, enableDebugPanel: true);

Render the built-in panel:

echo $manager->getDebugPanel()?->render();

Public DebugPanel methods:

  • addQuery(string $sql, array $bindings, float $timeMs, string $connection = 'default', ?string $error = null): void
  • all(): array
  • clear(): void
  • render(): string

Optional memran/marwa-debugbar

If memran/marwa-debugbar is installed as a dev dependency, Bootstrap::init(..., enableDebugPanel: true) will attach it automatically.

Render through the manager:

echo $manager->renderDebugBar();

Render through the package helper:

echo \Marwa\DB\Support\db_debugbar();

The helper reads the global manager from $GLOBALS['cm'] when no explicit manager is passed.

Query logger

Marwa\DB\Logger\QueryLogger stores query records in memory and can mirror them to a PSR-3 logger.

Public methods:

  • log(string $sql, array $bindings, float $timeMs, string $connection, ?string $error = null): void
  • all(): array
  • clear(): void

CLI

The repository includes a Symfony Console entrypoint:

php bin/marwa-db list

Available commands:

  • migrate
  • migrate:rollback
  • migrate:refresh
  • migrate:status
  • make:migration
  • make:seeder
  • db:seed

Examples:

php bin/marwa-db migrate
php bin/marwa-db migrate:status
php bin/marwa-db make:migration create_users_table
php bin/marwa-db make:seeder UsersTableSeeder
php bin/marwa-db db:seed
php bin/marwa-db db:seed --only=UsersTableSeeder

Testing and Quality

Run the full test suite:

composer test

Run unit tests only:

composer test:unit

Run integration tests:

composer test:integration

Real database integration tests use MARWA_DB_INTEGRATION=1 for MySQL and MARWA_DB_PG_INTEGRATION=1 for PostgreSQL, together with their documented MARWA_DB_* connection variables. GitHub Actions provisions MySQL 8.4 and PostgreSQL 16 service containers automatically.

Run static analysis:

composer analyze

Run syntax linting:

composer lint

Run the standard CI gate locally:

composer run ci

Coverage is reported in the GitHub Actions job logs. CI tests PHP 8.2, 8.3, 8.4, and 8.5.

Integration Notes

  • DB::setManager(...) must be called before using the DB facade.
  • Model::setConnectionManager(...) must be called before using the ORM.
  • Schema::init(...) must be called before using the static schema facade unless you rely on the global manager created by Bootstrap::init(...).
  • Query logging is automatic for all SQL executed through ConnectionManager::getPdo().
  • Connection, query, schema, and ORM failures use package exception types while preserving the original exception through getPrevious().
  • memran/marwa-debugbar is optional and intended for development use.

Security Notes

  • Do not commit production credentials.
  • Keep debug tooling disabled outside trusted environments.
  • Prefer configuration loaded from environment-aware application code.
  • Treat rendered debug output as sensitive because it may contain SQL, bindings, request state, and exception details.

License

MIT. See LICENSE.

Community