memran / marwa-db
Lightweight, framework-agnostic PHP database toolkit: connections, query builder, ORM, schema, migrations, seeders, debug panel.
Requires
- php: ^8.2
- ext-json: *
- ext-pdo: *
- memran/marwa-support: ^1.3.1
- psr/log: ^3.0
- symfony/console: ^7.0 || ^8.0
- symfony/string: ^7.3 || ^8.0
Requires (Dev)
- fakerphp/faker: ^1.23
- memran/marwa-debugbar: ^1.1.0
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^10.5.62
README
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-debugbarintegration
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-pdoext-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\BootstrapMarwa\DB\Connection\ConnectionManagerMarwa\DB\Facades\DBMarwa\DB\Query\BuilderMarwa\DB\ORM\ModelMarwa\DB\Schema\SchemaMarwa\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(...)andSchema::drop(...) - render debugging output with
echo $manager->renderDebugBar()whenmemran/marwa-debugbaris 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:
driverhostportdatabaseusernamepasswordcharsetoptionsdebug
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
DebugPanelis attached QueryLoggeris attachedmemran/marwa-debugbaris attached automatically if installed
ConnectionManager
Common public methods:
getPdo(?string $name = 'default'): PDOgetConnection(?string $name = 'default'): PDOtransaction(callable $callback, ?string $connectionName = null): mixedbeginTransaction(?string $connectionName = null): voidcommit(?string $connectionName = null): voidrollBack(?string $connectionName = null): voidtransactionLevel(?string $connectionName = null): intsetDebugPanel(?DebugPanel $panel): voidgetDebugPanel(): ?DebugPanelsetDebugBar(?object $debugBar): voidgetDebugBar(): ?objectrenderDebugBar(): stringsetQueryLogger(?QueryLogger $queryLogger): voidgetQueryLogger(): ?QueryLoggerisDebug(string $name = 'default'): boolgetDriver(string $name = 'default'): stringpickReplica(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): ConnectionManagerDB::beginTransaction(string $conn = 'default'): voidDB::commit(string $conn = 'default'): voidDB::rollback(string $conn = 'default'): voidDB::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): selffrom(string $table): selfselect(string ...$columns): selfselectRaw(string $expression, array $bindings = []): self
Filtering and ordering:
when(mixed $value, callable $callback, ?callable $default = null): selfunless(mixed $value, callable $callback, ?callable $default = null): selfwhere(string $column, string $operator, mixed $value, string $boolean = 'and'): selforWhere(string $column, string $operator, mixed $value): selfwhereKey(int|string|array $id): selfwhereIn(string $column, array $values, bool $not = false, string $boolean = 'and'): selfwhereNotIn(string $column, array $values, string $boolean = 'and'): selfwhereNull(string $column, string $boolean = 'and'): selfwhereNotNull(string $column, string $boolean = 'and'): selfwhereJsonContains(string $column, mixed $value): selfwhereJsonLength(string $column, int $length): selfwhereJsonValue(string $column, string $path, mixed $value): selforderBy(string $column, string $direction = 'asc'): selflimit(int $n): selfoffset(int $n): self
Reading:
get(int $fetchMode = PDO::FETCH_ASSOC): arrayfirst(int $fetchMode = PDO::FETCH_ASSOC): array|object|nullvalue(string $column): mixedpluck(string $column): Collectioncount(string $column = '*'): intmax(string $column): mixedmin(string $column): mixedsum(string $column): int|float|nullavg(string $column): ?floatpaginate(int $perPage = 15, int $page = 1, int $fetchMode = PDO::FETCH_ASSOC): arraywithCount(string ...$relations): self
Writing:
insert(array $data): int- insert one row or a list of rowsinsertOrIgnore(array $data): intupsert(array $data, array|string $uniqueBy, ?array $update = null): intinsertGetId(array $data): int|stringupdate(array $data): intdelete(): intincrement(string $column, int|float $amount = 1): intdecrement(string $column, int|float $amount = 1): int
Concurrency:
lockForUpdate(): selfsharedLock(): 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(): stringgetBindings(): arrayclear(): 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. User → users, UserProfile → user_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): voidModel::created(callable $callback): voidModel::updating(callable $callback): voidModel::updated(callable $callback): voidModel::saving(callable $callback): voidModel::saved(callable $callback): voidModel::deleting(callable $callback): voidModel::deleted(callable $callback): voidModel::restoring(callable $callback): voidModel::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): voidsetConnectionManager(ConnectionManager $cm, string $connection = 'default'): voidtable(): string
Query entry points:
query(): Marwa\DB\ORM\QueryBuildernewQuery(): Marwa\DB\ORM\QueryBuilderon(string $connection): Marwa\DB\ORM\QueryBuilderwhere(string $col, mixed $op, mixed $val = null): Marwa\DB\ORM\QueryBuilderwhereKey(int|string|array $id): Marwa\DB\ORM\QueryBuilderwhereIn(string $col, array $values): Marwa\DB\ORM\QueryBuilderwhereNull(string $col): Marwa\DB\ORM\QueryBuilderwhereNotNull(string $col): Marwa\DB\ORM\QueryBuilderwithCount(string ...$relations): Marwa\DB\ORM\QueryBuilderall(): arrayfind(int|string $id): ?staticfindOrFail(int|string $id): staticfirst(): ?staticfirstWhere(string $col, mixed $op, mixed $val = null): ?staticfirstOrFail(): ModelupdateOrCreate(array $attributes, array $values = []): staticfirstOrCreate(array $attributes, array $values = []): staticexists(): boolcount(string $col = '*'): intpaginate(int $perPage = 15, int $page = 1): arraychunk(int $size, callable $callback): voidchunkById(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): staticsave(): booldestroy(int|array $ids): inttouch(?string $attribute = null): bool
Timestamps:
usesTimestamps(): boolcreatedAt(): ?stringupdatedAt(): ?string
State and change tracking:
isDirty(?string $attribute = null): boolisClean(?string $attribute = null): boolsyncOriginal(): staticrefresh(): staticfresh(): ?static
Attribute and serialization helpers:
fill(array $attributes): staticforceFill(array $attributes): staticonly(array $keys): arrayexcept(array $keys): arraygetDirty(): arraygetKey(): int|string|nullgetKeyName(): stringgetAttribute(string $key): mixed— applies type casts on readsetAttribute(string $key, mixed $value): statichasAttribute(string $key): boolappend(array $attributes): staticsetHidden(array $hidden): staticsetVisible(array $visible): staticreplicate(?array $except = null): staticfill(array $attributes): static— respects$fillable/$guarded
Type casting:
casts(): arraycastIn(string $key, mixed $value): mixedcastOut(string $key, mixed $value): mixedmergeCasts(array $casts): statictoArray(): arraytoJson(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— marksdeleted_atwhen$softDeletesis trueforceDelete(): bool— hard deletes even with soft deletes enabledrestore(): booltrashed(): boolwithTrashed(): Marwa\DB\ORM\QueryBuilder— includes soft-deleted rowsonlyTrashed(): 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): voidwithoutGlobalScope(string $identifier): static- Local scopes: methods named
scopeXxx()are callable as->xxx(),Model::xxx(), orModel::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): selfselectRaw(string $expr, array $bindings = []): selfwhere(callable|string $col, mixed $op = null, mixed $val = null): selforWhere(callable|string $col, mixed $op = null, mixed $val = null): selfwhereColumn(string $first, mixed $operator, ?string $second = null): selforWhereColumn(...): selfwhereRaw(string $sql, array $bindings = []): selforWhereRaw(...): selfwhereIn(string $col, array $values): selfwhereNotIn(string $col, array $values): selfwhereNull(string $col): selfwhereNotNull(string $col): selfwhereBetween(string $col, array $values): selfwhereNotBetween(string $col, array $values): selfwhereExists(callable|Builder $subquery): selfwhereNotExists(...): selfwhereJsonContains(string $col, mixed $value): selfwhereJsonLength(string $col, int $length): selfwhereJsonValue(string $col, string $path, mixed $value): selfwhereNested(callable $callback, string $boolean = 'and'): self
Grouping / ordering:
groupBy(string ...$cols): selfgroupByRaw(string $expression): selfhaving(string $col, string $op, mixed $val): selforHaving(string $col, string $op, mixed $val): selfhavingRaw(string $sql, array $bindings = []): selforderBy(string $col, string $dir = 'asc'): selflimit(int $n): selfoffset(int $n): self
Joins:
join(string $table, string $first, string $operator, string $second): selfleftJoin(...): selfrightJoin(...): self
Reads:
get(): array<Model>first(): ?ModelfirstOrFail(): Modelvalue(string $column): mixedpluck(string $column): Collectioncount(string $col = '*'): intexists(): boolmax(string $col): mixedmin(string $col): mixedsum(string $col): int|float|nullavg(string $col): ?floatpaginate(int $perPage = 15, int $page = 1): arraychunk(int $size, callable $callback): voidchunkById(int $size, callable $callback, string $idCol = 'id'): void
Writes:
insert(array $data): intinsertGetId(array $data): int|stringupdate(array $data): intdelete(): intincrement(string $column, int|float $amount = 1): intdecrement(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(): stringgetBindings(): arrayclear(): 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): BuilderBuilder::create(string $table, Closure $callback): voidBuilder::table(string $table, Closure $callback): voidBuilder::drop(string $table): voidBuilder::rename(string $from, string $to): voidBuilder::hasTable(string $table): boolBuilder::hasColumn(string $table, string $column): boolBuilder::getForeignKeys(string $table): arrayBuilder::databaseVersion(): stringBuilder::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 commentprimary()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(): voidmigrate(): introllbackLastBatch(): introllbackAll(): intrefresh(): arraygetRanWithDetails(): arraygetMigrationFiles(): 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 = []): voidrunOne(string $fqcn, bool $wrapInTransaction = true): voiddiscoverSeeders(): 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): voidall(): arrayclear(): voidrender(): 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): voidall(): arrayclear(): void
CLI
The repository includes a Symfony Console entrypoint:
php bin/marwa-db list
Available commands:
migratemigrate:rollbackmigrate:refreshmigrate:statusmake:migrationmake:seederdb: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 theDBfacade.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 byBootstrap::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-debugbaris 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
- Read the Code of Conduct
- See Contributing for workflow and PR guidance
- Report security issues through SECURITY.md