hiblaphp/database
最新稳定版本:1.0.0-beta.1
Composer 安装命令:
composer require hiblaphp/database
包简介
The complete asynchronous, framework-agnostic database layer toolkit for the Hibla Ecosystem (Query Builder, Migrations, and Seeders)
README 文档
README
The official documentation for the Hibla PHP database ecosystem: a fully asynchronous, framework-agnostic database layer built on fibers/promises and non-blocking I/O.
Table of Contents
- Overview
- Quick Start
- Packages
- Requirements
- Supported Drivers
- Installation
- Configuration
- Getting Started
- Query Builder
- Schema Manager
- License
Here is the updated, highly engaging Overview section for your main README, written strictly in pure Markdown (no HTML).
It is designed to immediately sell the practical value of the library by showing how it solves the traditional "async vs. convenience" compromise in PHP:
Overview
Hibla Database is an asynchronous, framework-agnostic database layer for modern PHP 8.4+. It is built specifically for long-running, high-concurrency environments (such as Swoole, RoadRunner, or Workerman) and standalone microframeworks (like Slim or Leaf).
This is not a heavy, blocking ORM. It is a highly optimized, non-blocking database lifecycle suite (Query Builder + Schema CLI) designed to be composed into whatever architecture your application needs.
Why Use this Library?
Historically, PHP developers building high-concurrency applications had to make a painful compromise: either write raw, low-level SQL strings over raw async socket drivers (excellent performance, terrible developer experience), or pull in a bloated full-stack monolith just to get decent database and migration tooling.
Hibla eliminates this compromise completely by delivering three core pillars:
- True Asynchronous Power: By operating directly over non-blocking socket streams instead of blocking PDO, Hibla releases the Fiber event loop on every query. While your database is executing a query, your server continues to process other concurrent requests, unlocking massive throughput and concurrency.
- Familiar, World-Class Developer Experience (DX): Hibla maps the asynchronous paradigm directly onto the highly beloved, expressive fluent syntax of Laravel's query builder and schema blueprints. If you know Laravel, there is zero learning curve so you write queries the exact same way and simply wrapping your executions in
await(). - Zero-Friction Setup: Bootstrapping a standalone database in a microframework or custom app is traditionally a tedious process. Hibla provides a single-command initializer (
init) that auto-scaffolds your configurations, supports instant directory auto-discovery, and utilizes lightweight anonymous classes so you never have to modify yourcomposer.jsonnamespaces to run migrations or seeders.
Quick Start
composer require hiblaphp/database
Add your credentials to a .env file:
DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 DB_DATABASE=myapp DB_USERNAME=root DB_PASSWORD=secret
Copy the default config to your project root:
./vendor/bin/hibla-db init
Then run your first query:
<?php require 'vendor/autoload.php'; use Hibla\QueryBuilder\DB; use function Hibla\await; $users = await(DB::table('users')->where('active', true)->get()); foreach ($users as $user) { echo $user->name . PHP_EOL; } DB::close();
The sections below cover configuration, the full query builder API, and the schema manager in detail.
Packages
| Package | Description | Standalone |
|---|---|---|
hiblaphp/database |
Meta package that installs both packages below | ✅ Yes |
hiblaphp/query-builder |
Async query builder, connection pooling, transactions, pagination | ✅ Yes |
hiblaphp/schema-manager |
Migrations, seeders, CLI tooling | Requires query-builder |
hiblaphp/databaseis a convenience meta package that pulls in bothhiblaphp/query-builderandhiblaphp/schema-managertogether. If you want the full stack, install that instead. The two packages are documented here under a single unified reference because of it.
Requirements
- PHP 8.4 or higher
- Fiber support (PHP 8.1+, enabled by default in 8.4)
- MySQL 8.0+ or PostgreSQL 15+
Supported Drivers
| Driver | Status | Notes |
|---|---|---|
| MySQL / MariaDB | ✅ | Full support including locking, JSON, CTEs |
| PostgreSQL | ✅ | Full support including JSONB, vector columns, schema dump |
| SQLite | ⚠️ | No async support yet; schema manager has limited ALTER support |
SQLite currently has no async driver support. An async SQLite client is in development and will be integrated into the query builder once ready, bringing SQLite to full parity with the MySQL and PostgreSQL drivers.
Installation
Query Builder Only
This package is currently in beta. Before installing, ensure your
composer.jsonallows beta releases:
If you only need the query builder without schema management:
composer require hiblaphp/query-builder
Then copy the default config file to your project root:
cp vendor/hiblaphp/query-builder/hibla-database.php hibla-database.php
With Schema Manager
The schema manager includes the query builder as a dependency, so you only need one command:
composer require hiblaphp/schema-manager
Then run the init command to scaffold all config files at once:
./vendor/bin/hibla-db init
Configuration
The Config File
The query builder looks for hibla-database.php in your project root or in a config/ subdirectory. This file returns a plain PHP array:
<?php // hibla-database.php use function Rcalicdan\ConfigLoader\env; require 'vendor/autoload.php'; return [ /* |-------------------------------------------------------------------------- | Default Connection |-------------------------------------------------------------------------- */ 'default' => env('DB_CONNECTION', 'mysql'), /* |-------------------------------------------------------------------------- | Database Connections |-------------------------------------------------------------------------- */ 'connections' => [ 'mysql' => [ 'driver' => 'mysql', 'host' => env('DB_HOST', '127.0.0.1'), 'port' => env('DB_PORT', 3306, convertNumeric: true), 'database' => env('DB_DATABASE', 'myapp'), 'username' => env('DB_USERNAME', 'root'), 'password' => env('DB_PASSWORD', ''), 'charset' => 'utf8mb4', // Connection pool 'max_connections' => env('DB_MAX_CONNECTIONS', 10, convertNumeric: true), 'min_connections' => env('DB_MIN_CONNECTIONS', 0, convertNumeric: true), 'idle_timeout' => 60, 'max_lifetime' => 3600, 'acquire_timeout' => 10.0, ], 'pgsql' => [ 'driver' => 'pgsql', 'host' => env('DB_HOST', '127.0.0.1'), 'port' => env('DB_PORT', 5432, convertNumeric: true), 'database' => env('DB_DATABASE', 'myapp'), 'username' => env('DB_USERNAME', 'postgres'), 'password' => env('DB_PASSWORD', ''), 'max_connections' => env('DB_MAX_CONNECTIONS', 10, convertNumeric: true), 'min_connections' => env('DB_MIN_CONNECTIONS', 0, convertNumeric: true), ], ], /* |-------------------------------------------------------------------------- | Pagination |-------------------------------------------------------------------------- | | templates_path — null uses the built-in templates. Set to an absolute | directory path after running `publish:templates` to use custom templates. | | default_template — template used by paginate() | default_cursor_template — template used by cursorPaginate() | | Available built-in names: tailwind, bootstrap, simple, | cursor-tailwind, cursor-bootstrap, cursor-simple */ 'pagination' => [ 'templates_path' => null, 'default_template' => 'tailwind', 'default_cursor_template' => 'cursor-tailwind', ], ];
Environment Variables
Create a .env file in your project root:
DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 DB_DATABASE=myapp DB_USERNAME=root DB_PASSWORD=secret
Custom Config Locations
If your config files live somewhere other than the project root or config/, point to them with environment variables:
# Path relative to project root, without .php extension HIBLA_DB_CONFIG=app/config/database HIBLA_MIGRATIONS_CONFIG=app/config/migrations HIBLA_SEEDERS_CONFIG=app/config/seeders
The init command handles this automatically when you use the --dir or custom name options, and it prints the exact variables you need to add:
# Place all configs in a config/ directory ./vendor/bin/hibla-db init --dir=config # Custom file names ./vendor/bin/hibla-db init \ --db-config=database \ --migrations-config=migrations \ --seeders-config=seeders
Getting Started
Bootstrapping the Connection
Connections are lazy and nothing opens until the first query. The DB facade initializes the DatabaseManager on first use, which reads your config and sets up the connection pool.
<?php require 'vendor/autoload.php'; use Hibla\QueryBuilder\DB; use function Hibla\await; use function Hibla\run; $users = await(DB::table('users')->where('active', true)->get()); foreach ($users as $user) { echo $user->name . PHP_EOL; } DB::close(); // close the pool when done
Using the DB Facade
DB is a static facade over a singleton DatabaseManager instance. The manager is created on the first call to any DB method and reused for the lifetime of the process. Being a singleton does not mean being limited to one database and the manager maintains a registry of named connection pools, and you can register as many as your application needs. Each pool is lazy: nothing opens until the first query hits that connection.
use Hibla\QueryBuilder\DB; use function Hibla\await; // Default connection — resolves from DB_CONNECTION in your config $user = await(DB::table('users')->find(1)); // A second named connection — its pool is opened independently on first use $stats = await(DB::connection('pgsql')->table('events')->count()); // A third connection registered at runtime $orders = await(DB::connection('tenant_acme')->table('orders')->get());
Each named connection has its own isolated pool with its own min_connections, max_connections, and lifecycle settings. Queries on mysql never share a pool slot with queries on pgsql or any other registered connection — they are fully independent.
Because DB is a singleton, all code in the same process shares the same pool registry. For testing, DB::reset() destroys the singleton entirely so the next call starts fresh.
Closing Connections
// Close one specific pool DB::close('pgsql'); // Close all registered pools DB::close(); // Async variants (returns PromiseInterface<void>) await(DB::closeAsync('pgsql')); await(DB::closeAsync());
Calling DB::close() without a name iterates every registered connection and closes its pool. The async variant does the same but waits for all pools to drain cleanly before resolving.
Calling close() or closeAsync() is not strictly required. If you do not call it, the underlying connection pool will be closed automatically when the DatabaseManager instance is garbage collected. You should close explicitly when you want to control the shutdown order and for example, at the end of a long-running worker loop, or in a test teardown to ensure pools do not bleed between test cases.
Testing with setSqlClient
DB::setSqlClient() is the intended escape hatch for injecting a mock or in-memory client without touching any config file. It registers the client as the default connection and sets it as the active default:
use Hibla\QueryBuilder\DB; use Hibla\QueryBuilder\Enums\DatabaseDriver; // Inject a mock client before the test runs DB::setSqlClient($mockSqlClient, DatabaseDriver::Mysql); // All DB::table() calls now run against the mock $user = await(DB::table('users')->find(1)); // Tear down after the test DB::reset();
For services that use constructor injection via DatabaseConnectionInterface, you can swap the binding in the container instead and never touch DB directly and see Dependency Injection Mode.
Dynamic Connection Management
addConnection() and removeConnection() let you register and deregister named connections at runtime without touching the config file. This is most useful when building custom schema builders, multi-tenant systems, or tooling that resolves connections dynamically:
use Hibla\QueryBuilder\DB; use Hibla\QueryBuilder\Internals\DatabaseConnection; // Register a connection built from an arbitrary config array $client = DB::resolveClientFromConfig([ 'driver' => 'pgsql', 'host' => $tenantHost, 'database' => $tenantDatabase, 'username' => $tenantUser, 'password' => $tenantPassword, 'max_connections' => 5, ]); $connection = new DatabaseConnection($client, 'pgsql'); DB::addConnection('tenant_' . $tenantId, $connection); // Use it by name $orders = await(DB::connection('tenant_' . $tenantId)->table('orders')->get()); // Remove and close the pool when done DB::removeConnection('tenant_' . $tenantId);
removeConnection() closes the pool synchronously before deregistering the name, so the connection is fully cleaned up in one call. If you need to close it asynchronously first, call closeAsync() on the connection before removing it:
$conn = DB::connection('tenant_' . $tenantId); await($conn->closeAsync()); DB::removeConnection('tenant_' . $tenantId);
Raw Queries
All raw methods bypass the query builder entirely and execute SQL directly against the active connection. They are useful for complex queries that the builder cannot express, database-specific syntax, or bulk operations where you want full control.
// Execute and return all rows $results = await(DB::raw('SELECT * FROM users WHERE active = ?', [true])); // Returns array<int, array<string, mixed>> // Return only the first row, or null $user = await(DB::rawFirst('SELECT * FROM users WHERE id = ?', [1])); // Return a single scalar value from the first column of the first row $count = await(DB::rawValue('SELECT COUNT(*) FROM users WHERE active = ?', [true])); // Execute a write statement — returns number of affected rows $affected = await(DB::rawExecute( 'UPDATE users SET last_seen_at = NOW() WHERE id IN (?, ?, ?)', [1, 2, 3] )); // Execute and return an unbuffered row stream (for large result sets) $stream = await(DB::rawStream( 'SELECT * FROM events WHERE created_at > ?', ['2024-01-01'], bufferSize: 200 )); foreach ($stream as $row) { handle($row); } $stream->cancel(); // stop early if needed
All five raw methods are also available directly on any DatabaseConnectionInterface instance:
$conn = DB::connection('pgsql'); $results = await($conn->raw($sql, $bindings)); $first = await($conn->rawFirst($sql, $bindings)); $value = await($conn->rawValue($sql, $bindings)); $affected = await($conn->rawExecute($sql, $bindings)); $stream = await($conn->rawStream($sql, $bindings, bufferSize: 100));
Raw methods pass bindings as a flat positional array (
[true],[1, 2, 3]). They do not go through the query builder's binding compiler, so the caller is responsible for ordering values correctly relative to the?placeholders.
Dependency Injection Mode
For testable architectures, the query builder exposes clean interfaces. Prefer injecting these over depending on the static DB facade in your services.
Choosing an Interface
| Interface | Use when |
|---|---|
DatabaseConnectionInterface |
Your service needs transactions, raw queries, or dynamic table access |
QueryBuilderInterface |
Your service is scoped to one table and may need transactions |
BaseQueryBuilderInterface |
Your service only reads data and never needs to start transactions |
Constructor Injection
<?php namespace App\Repositories; use Hibla\Promise\Interfaces\PromiseInterface; use Hibla\QueryBuilder\Interfaces\DatabaseConnectionInterface; class UserRepository { public function __construct( private readonly DatabaseConnectionInterface $db ) {} /** @return PromiseInterface<list<array<string, mixed>>> */ public function getActive(): PromiseInterface { return $this->db->table('users') ->where('active', true) ->latest() ->get(); } /** @return PromiseInterface<array<string, mixed>|null> */ public function findById(int $id): PromiseInterface { return $this->db->table('users')->find($id); } /** @return PromiseInterface<int> */ public function create(array $data): PromiseInterface { return $this->db->table('users')->insertGetId($data); } }
When your service is scoped to a single table and may need transactions, use QueryBuilderInterface:
use Hibla\QueryBuilder\Interfaces\QueryBuilderInterface; class PostRepository { public function __construct( private readonly QueryBuilderInterface $query ) {} public function published(): PromiseInterface { return $this->query->where('published', true)->latest()->get(); } }
When your service only reads data and never needs to start transactions, use BaseQueryBuilderInterface as the narrowest possible contract.
Registering with a DI Container
Bind the interfaces to concrete instances in your container bootstrap. The pattern applies to any PSR-11-compatible container:
<?php // bootstrap/container.php use Hibla\QueryBuilder\DB; use Hibla\QueryBuilder\Interfaces\DatabaseConnectionInterface; use Hibla\QueryBuilder\Interfaces\QueryBuilderInterface; // Bind the default connection $container->bind(DatabaseConnectionInterface::class, fn() => DB::connection()); // Bind a named connection $container->bind('db.analytics', fn() => DB::connection('pgsql')); // Bind a pre-scoped query builder for a specific service $container->bind(QueryBuilderInterface::class, fn() => DB::connection()->table('users'));
Usage in a service:
<?php namespace App\Services; use App\Repositories\UserRepository; use Hibla\QueryBuilder\Interfaces\DatabaseConnectionInterface; use function Hibla\await; class UserService { public function __construct( private readonly UserRepository $users, private readonly DatabaseConnectionInterface $db ) {} public function register(array $data): int { return await( $this->db->transaction(function ($tx) use ($data) { $userId = await($tx->table('users')->insertGetId($data)); await($tx->table('user_profiles')->insert(['user_id' => $userId])); return $userId; }) ); } }
For testing, swap the
DatabaseConnectionInterfacebinding with a mock or an in-memory connection without changing any service code.
Query Builder
All query builder methods return a new immutable instance and calling a method never modifies the original. See the query builder primitives documentation for full details on immutability. The sections below document the execution side.
Default result shape:
stdClassobjects. Execution methods that return rows (get(),first(),find(),findOrFail(),pluck(), and the streaming methods) returnstdClassobjects by default. If you prefer associative arrays, calltoArray()anywhere in the chain before executing. To be explicit about objects, usetoObject(). Both methods return a new immutable instance and do not affect the original query.// stdClass — default, no call needed $users = await(DB::table('users')->get()); echo $users[0]->name; // Associative array $users = await(DB::table('users')->toArray()->get()); echo $users[0]['name']; // Explicit object (same as default, useful for clarity in DI contexts) $users = await(DB::table('users')->toObject()->get()); echo $users[0]->name;The
toArray()/toObject()setting is scoped to the query instance it is called on. It does not affect other queries built from the same base or any global state.
Reading Data
Fetching Data
use Hibla\QueryBuilder\DB; use function Hibla\await; // All rows $users = await(DB::table('users')->get()); // First row, or null $user = await(DB::table('users')->where('email', 'alice@example.com')->first()); // First row or throw RecordNotFoundException $user = await(DB::table('users')->where('status', 'active')->firstOrFail()); // Find by primary key $user = await(DB::table('users')->find(42)); // Find or throw RecordNotFoundException $user = await(DB::table('users')->findOrFail(42));
The difference between firstOrFail() and findOrFail() is intent. findOrFail() is for looking up a specific record by a known ID and it adds a WHERE id = ? clause for you. firstOrFail() is for when you have already built your conditions and simply want to assert that at least one matching record exists, throwing if none do.
// findOrFail — shorthand for a primary key lookup $user = await(DB::table('users')->findOrFail(42)); // firstOrFail — asserts a match exists for your own conditions $user = await( DB::table('users') ->where('email', $email) ->where('active', true) ->firstOrFail() ); // Single column value from first row $email = await(DB::table('users')->where('id', 1)->value('email')); // Array of values from one column $emails = await(DB::table('users')->where('active', true)->pluck('email')); // ['alice@example.com', 'bob@example.com'] // key => value pairs $names = await(DB::table('users')->pluck('name', 'id')); // [1 => 'Alice', 2 => 'Bob'] // Results as objects (default) $users = await(DB::table('users')->toObject()->get()); // $users[0]->name // Results as associative arrays $users = await(DB::table('users')->toArray()->get()); // $users[0]['name']
Selecting Columns
// Specific columns $users = await(DB::table('users')->select('id', 'name', 'email')->get()); // Add columns to an existing select $query = DB::table('users')->select('id', 'name'); $query = $query->addSelect('email', 'created_at'); // DISTINCT $countries = await(DB::table('users')->selectDistinct('country')->get());
Where Conditions
// Equality (two-argument shorthand) $active = await(DB::table('users')->where('status', 'active')->get()); // With explicit operator $seniors = await(DB::table('users')->where('age', '>=', 65)->get()); // OR WHERE $results = await( DB::table('users') ->where('status', 'active') ->orWhere('status', 'pending') ->get() ); // WHERE IN / NOT IN $selected = await(DB::table('users')->whereIn('id', [1, 2, 3])->get()); $excluded = await(DB::table('users')->whereNotIn('role', ['guest', 'banned'])->get()); // BETWEEN $range = await(DB::table('orders')->whereBetween('total', [100, 500])->get()); // NULL checks $unverified = await(DB::table('users')->whereNull('verified_at')->get()); $verified = await(DB::table('users')->whereNotNull('verified_at')->get()); // LIKE — side: 'both' (default), 'before', 'after' $search = await(DB::table('users')->like('name', 'john')->get()); // name LIKE '%john%' $starts = await(DB::table('users')->like('username', 'admin', 'after')->get()); // username LIKE 'admin%' // Column comparison — no binding, no injection risk $mismatch = await(DB::table('audit_log')->whereColumn('expected_hash', '!=', 'actual_hash')->get()); // Reset all WHERE conditions $base = DB::table('users')->where('active', true); $clean = $base->resetWhere();
Advanced Conditions
// Grouped conditions — (status = ? AND role = ?) OR (status = ? AND invited = ?) $results = await( DB::table('users') ->whereGroup(fn($q) => $q->where('status', 'active')->where('role', 'admin')) ->orWhereNested(fn($q) => $q->where('status', 'pending')->where('invited', true)) ->get() ); // EXISTS subquery $usersWithOrders = await( DB::table('users') ->whereExists(function ($q) { return $q->from('orders') ->whereRaw('orders.user_id = users.id') ->where('total', '>', 1000); }) ->get() ); // NOT EXISTS $usersWithoutOrders = await( DB::table('users') ->whereNotExists(function ($q) { return $q->from('orders')->whereRaw('orders.user_id = users.id'); }) ->get() ); // Subquery in WHERE $results = await( DB::table('products') ->whereSub('price', '>', function ($q) { return $q->from('products')->selectRaw('AVG(price)'); }) ->get() );
Raw Expressions
Whenever you need to bypass the query builder's abstraction and write raw SQL conditions or projections, use the *Raw methods.
Security Note: Always use parameter bindings (
?) for any user-provided input inside raw expressions to prevent SQL injection. Do not concatenate strings directly into raw SQL.
// selectRaw — easily inject functions or complex aliases $users = await(DB::table('users') ->selectRaw('COUNT(*) as total, YEAR(created_at) as year') ->groupByRaw('YEAR(created_at)') ->get()); // whereRaw & orWhereRaw $users = await(DB::table('users') ->whereRaw('LENGTH(name) > ?', [10]) ->orWhereRaw('score > ? AND status = ?', [1000, 'active']) ->get()); // havingRaw & orHavingRaw $stats = await(DB::table('orders') ->selectRaw('user_id, SUM(total) as revenue') ->groupBy('user_id') ->havingRaw('SUM(total) - SUM(tax) > ?', [5000]) ->orHavingRaw('MAX(total) > ?', [1000]) ->get()); // orderByRaw — useful for functions or specific ordering mechanisms $customSort = await(DB::table('tasks') ->orderByRaw('FIELD(status, ?, ?, ?)', ['todo', 'in_progress', 'done']) ->get()); // groupByRaw $grouped = await(DB::table('users') ->selectRaw('DATE(created_at) as date, COUNT(*) as count') ->groupByRaw('DATE(created_at)') ->get());
Conditional Query Building
Apply constraints only when a value is present, with no if/else branches needed:
$search = $request->input('search'); $status = $request->input('status'); $isAdmin = $user->isAdmin(); $users = await( DB::table('users') ->when($search, fn($q, $v) => $q->like('name', $v)) ->when($status, fn($q, $v) => $q->where('status', $v)) ->unless($isAdmin, fn($q, $v) => $q->where('active', true)) ->latest() ->get() ); // With a default branch (runs when value is falsy) $posts = await( DB::table('posts') ->when( $sortColumn, fn($q, $v) => $q->orderBy($v, $sortDirection ?? 'ASC'), fn($q, $v) => $q->latest() // default if no sort given ) ->get() ); // Invokable class as condition class IsSubscribed { public function __invoke(mixed $builder): bool { return auth()->user()?->hasActiveSubscription() ?? false; } } $features = await( DB::table('features') ->when(new IsSubscribed(), fn($q) => $q->where('tier', 'premium')) ->get() );
JSON Conditions
JSON conditions compile to the correct driver dialect automatically (MySQL, PostgreSQL, SQLite):
// Scalar field comparison — two-argument shorthand defaults to '=' $dark = await(DB::table('users')->whereJson('options->theme', 'dark')->get()); $enabled = await(DB::table('users')->whereJson('options->notifications->email', true)->get()); // With explicit operator $highLevel = await(DB::table('users')->whereJson('settings->level', '>', 5)->get()); // OR JSON condition $admins = await( DB::table('users') ->whereJson('settings->role', 'admin') ->orWhereJson('settings->role', 'moderator') ->get() ); // Array contains $english = await(DB::table('users')->whereJsonContains('options->languages', 'en')->get()); // Array does NOT contain $noBlock = await(DB::table('users')->whereJsonDoesntContain('options->blocked', 'PH')->get()); // Array length $skilled = await(DB::table('users')->whereJsonLength('options->skills', '>', 3)->get()); $exact = await(DB::table('posts')->whereJsonLength('metadata->tags', '=', 0)->get()); // Combined with regular conditions $results = await( DB::table('users') ->where('active', true) ->whereJson('options->theme', 'dark') ->whereJsonContains('options->languages', 'en') ->whereJsonLength('options->skills', '>=', 2) ->get() );
Joins
// Simple string condition $orders = await( DB::table('orders') ->leftJoin('users', 'users.id = orders.user_id') ->select('orders.*', 'users.name as customer_name') ->get() ); // Closure — multi-condition join with value filters $orders = await( DB::table('orders') ->leftJoin('users', function ($join) { return $join ->on('users.id', '=', 'orders.user_id') ->where('users.active', true) ->whereNull('users.deleted_at'); }) ->get() ); // OR ON condition $contacts = await( DB::table('contacts') ->leftJoin('users', function ($join) { return $join ->on('users.email', '=', 'contacts.primary_email') ->orOn('users.email', '=', 'contacts.secondary_email'); }) ->get() ); // Multiple joins $report = await( DB::table('orders') ->leftJoin('users', 'users.id = orders.user_id') ->leftJoin('products', 'products.id = orders.product_id') ->leftJoin('payments', 'payments.order_id = orders.id') ->select('orders.id', 'users.name', 'products.title', 'payments.status') ->get() ); // CROSS JOIN $grid = await(DB::table('colors')->crossJoin('sizes')->get());
Grouping and Ordering
// GROUP BY — string, array, or comma-separated string $stats = await( DB::table('orders') ->select('user_id') ->selectRaw('COUNT(*) as total, SUM(total) as revenue') ->groupBy('user_id') ->having('total', '>', 5) ->get() ); $multi = await(DB::table('sales')->groupBy(['region', 'month'])->get()); // Standard ORDER BY $sorted = await(DB::table('posts')->orderBy('created_at', 'DESC')->orderBy('title', 'ASC')->get()); $desc = await(DB::table('posts')->orderByDesc('created_at')->get()); $asc = await(DB::table('posts')->orderByAsc('title')->get()); // Semantic aliases (default to created_at) $recent = await(DB::table('posts')->latest()->get()); // ORDER BY created_at DESC $oldest = await(DB::table('posts')->oldest()->get()); // ORDER BY created_at ASC $pubDesc = await(DB::table('posts')->latest('published_at')->get()); // Random order — adapts to driver (RAND() / RANDOM()) $sample = await(DB::table('products')->inRandomOrder()->limit(5)->get()); // reorder() — clear existing ORDER BY and optionally replace $base = DB::table('users')->orderByDesc('created_at'); $fresh = $base->reorder(); // clears all ORDER BY $renewed = $base->reorder('name', 'ASC'); // clears then sets a new one $raw = $base->reorder()->orderByRaw('RAND()'); // LIMIT and OFFSET $page = await(DB::table('users')->limit(10)->offset(20)->get()); $combo = await(DB::table('users')->limit(10, 20)->get()); // combined shorthand $pagined = await(DB::table('users')->forPage(3, 15)->get()); // page 3, 15 per page
Aggregates and Existence
// COUNT — always returns int $total = await(DB::table('users')->count()); $active = await(DB::table('users')->where('active', true)->count()); $distinct = await(DB::table('orders')->count('DISTINCT user_id')); // SUM, AVG, MIN, MAX $revenue = await(DB::table('orders')->where('status', 'completed')->sum('total')); $average = await(DB::table('orders')->avg('total')); $cheapest = await(DB::table('products')->min('price')); $priciest = await(DB::table('products')->max('price')); // Scoped aggregates $avgPremium = await( DB::table('orders') ->where('tier', 'premium') ->whereBetween('created_at', ['2024-01-01', '2024-12-31']) ->avg('total') ); // EXISTS — always returns bool $emailTaken = await(DB::table('users')->where('email', $email)->exists()); if ($emailTaken) { throw new \RuntimeException('Email already registered.'); } // DOESN'T EXIST — always returns bool $available = await(DB::table('users')->where('username', $username)->doesntExist()); // Existence check with JOIN $isEnrolled = await( DB::table('enrollments') ->where('user_id', $userId) ->where('course_id', $courseId) ->whereNull('cancelled_at') ->exists() );
Return types for
sum(),avg(),min(), andmax()These four methods return
mixed, the raw value as the database driver delivers it. In practice this is almost always astring, even when the underlying column isDECIMAL,FLOAT, orINT. Database drivers return numeric aggregates as strings to preserve precision; PHP has no native arbitrary-precision numeric type, so casting tointorfloatsilently drops precision on large or high-precision values.$total = await(DB::table('orders')->sum('total')); // $total is '9999999.99' (string) — not 9999999.99 (float)If you need to do arithmetic on the result, use
bcmathorbrick/mathrather than casting:$revenue = await(DB::table('orders')->sum('total')); $tax = await(DB::table('orders')->sum('tax')); // Safe — no precision loss $gross = bcadd((string) $revenue, (string) $tax, 2); // Unsafe — float precision loss on large or high-precision values $gross = (float) $revenue + (float) $tax;If you know the column is a small integer and precision is not a concern, a straight cast is fine:
$count = (int) await(DB::table('products')->sum('stock'));
count()is the one exception: it always returnsintbecause the query builder casts the result explicitly after fetching it.
Pagination
Offset Pagination
Standard page-based pagination. The current page is read automatically from $_GET['page']:
// Basic — 15 results per page $paginator = await(DB::table('users')->where('active', true)->paginate(15)); // With a custom base path for link generation $paginator = await(DB::table('users')->paginate(15, path: 'https://example.com/users'));
Available properties:
| Property | Type | Description |
|---|---|---|
$paginator->items |
array |
The rows for the current page |
$paginator->total |
int |
Total record count across all pages |
$paginator->perPage |
int |
Records per page |
$paginator->currentPage |
int |
Current page number |
$paginator->lastPage |
int |
Last page number |
$paginator->from |
int |
Starting record number on this page |
$paginator->to |
int |
Ending record number on this page |
$paginator->hasMore |
bool |
Whether a next page exists |
$paginator->hasPages |
bool |
Whether there is more than one page |
$paginator->isFirstPage |
bool |
Whether this is the first page |
$paginator->isLastPage |
bool |
Whether this is the last page |
$paginator->path |
string|null |
Base URL for link generation |
Available methods:
$paginator->url(3); // URL for a specific page $paginator->nextPageUrl(); // URL for the next page, or null $paginator->previousPageUrl(); // URL for the previous page, or null $paginator->getUrlRange(1, 5); // [1 => '...', 2 => '...', ...] $paginator->render(); // render HTML pagination links $paginator->render('bootstrap'); // render with a specific template $paginator->links(); // alias for render() $paginator->toArray(); // convert to array (for APIs) $paginator->toJson(); // convert to JSON string
Cursor Pagination
Cursor pagination is more efficient than offset pagination on large tables because it avoids COUNT(*) and deep OFFSET scans. It reads the cursor token from $_GET['cursor'] automatically:
// Single column cursor $paginator = await(DB::table('posts')->cursorPaginate(20, 'id')); // Multi-column cursor with directions $paginator = await( DB::table('posts') ->where('published', true) ->cursorPaginate(20, ['created_at' => 'desc', 'id' => 'asc']) );
Available properties:
| Property | Type | Description |
|---|---|---|
$paginator->items |
array |
The rows for the current window |
$paginator->perPage |
int |
Records per window |
$paginator->nextCursor |
string|null |
Encoded cursor token for the next page |
$paginator->hasMore |
bool |
Whether more records exist |
$paginator->cursorColumns |
array |
Columns and directions used for cursor sorting |
$paginator->path |
string|null |
Base URL for link generation |
Available methods:
$paginator->nextPageUrl(); // URL with ?cursor=... appended, or null $paginator->render(); // render HTML next-page link $paginator->render('cursor-bootstrap'); $paginator->links(); // alias for render() $paginator->toArray(); $paginator->toJson();
Pagination Templates
Six templates are built in. Configure the default in hibla-database.php:
| Template name | Style | Paginator type |
|---|---|---|
tailwind |
Tailwind CSS | Offset |
bootstrap |
Bootstrap 5 | Offset |
simple |
Plain HTML | Offset |
cursor-tailwind |
Tailwind CSS | Cursor |
cursor-bootstrap |
Bootstrap 5 | Cursor |
cursor-simple |
Plain HTML | Cursor |
To use a specific template for a single render:
echo $paginator->render('bootstrap'); echo $paginator->render('cursor-simple');
Publishing and customizing templates:
Set templates_path in your hibla-database.php before publishing:
'pagination' => [ 'templates_path' => __DIR__ . '/resources/views/pagination', 'default_template' => 'tailwind', 'default_cursor_template' => 'cursor-tailwind', ],
Then run the publish command (requires hiblaphp/schema-manager for CLI access):
./vendor/bin/hibla-db publish:templates # Publish to a custom path ./vendor/bin/hibla-db publish:templates --path=resources/views/pagination # Overwrite existing published templates ./vendor/bin/hibla-db publish:templates --force
The six .php template files will be copied to your configured directory. They are standard PHP templates with access to a $paginator variable. Published templates take priority over the built-in ones automatically.
Pagination for APIs
When building JSON APIs, use toArray() or toJson() instead of rendering HTML:
// Offset paginator $paginator = await(DB::table('users')->paginate(15)); return response()->json($paginator->toArray()); // { // "data": [...], // "meta": { "total": 100, "per_page": 15, "current_page": 1, "last_page": 7, "from": 1, "to": 15 }, // "links": { "first": "...", "last": "...", "prev": null, "next": "..." } // } // Cursor paginator $paginator = await(DB::table('posts')->cursorPaginate(20, 'id')); return response()->json($paginator->toArray()); // { // "data": [...], // "meta": { "per_page": 20, "has_more": true }, // "links": { "next": "...?cursor=eyJpZCI6MjB9" } // } // Exclude items (return only meta/links) return response()->json($paginator->toArray(includeItems: false)); // Override path for link generation return response()->json($paginator->toArray(basePath: 'https://api.example.com/v1/users'));
Writing Data
Inserting Data
// Single row — returns number of affected rows (usually 1) $affected = await(DB::table('users')->insert([ 'name' => 'Alice', 'email' => 'alice@example.com', ])); // Insert Ignore — skip inserting if a unique constraint is violated (returns 0) $affected = await(DB::table('users')->insertIgnore([ 'name' => 'Alice Clone', 'email' => 'alice@example.com', ])); // Single row — inserts data and returns the new auto-increment ID. // By default, this method retrieves and returns the value of the 'id' column. $id = await(DB::table('users')->insertGetId([ 'name' => 'Bob', 'email' => 'bob@example.com', ])); // If your database table uses a custom primary key name (particularly important // for PostgreSQL sequences), you can override the default 'id' by passing your // custom column name as the second parameter: $adminId = await(DB::table('admins')->insertGetId($adminData, 'admin_id')); // PostgreSQL requires the sequence/primary key name when it differs from 'id' $id = await(DB::table('admins')->insertGetId($data, 'admin_id')); // Batch insert — one SQL statement for all rows $affected = await(DB::table('tags')->insertBatch([ ['name' => 'php', 'slug' => 'php'], ['name' => 'javascript', 'slug' => 'javascript'], ['name' => 'python', 'slug' => 'python'], ])); $affected = await(DB::table('tags')->insertIgnoreBatch([ ['name' => 'php', 'slug' => 'php'], // Skipped ['name' => 'python', 'slug' => 'python'], // Inserted ])); // Upsert — insert or update on conflict (single row) // Compiles to ON DUPLICATE KEY UPDATE (MySQL) / ON CONFLICT DO UPDATE (PgSQL/SQLite) $affected = await(DB::table('user_settings')->upsert( ['user_id' => 1, 'theme' => 'dark', 'language' => 'en'], uniqueColumns: 'user_id', updateColumns: ['theme', 'language'] // null = update all non-unique columns )); // Batch upsert $affected = await(DB::table('products')->upsertBatch( [ ['sku' => 'ABC', 'stock' => 10, 'price' => 9.99], ['sku' => 'DEF', 'stock' => 5, 'price' => 14.99], ], uniqueColumns: ['sku'], updateColumns: ['stock', 'price'] )); // Upsert with composite unique key $affected = await(DB::table('product_prices')->upsert( ['product_id' => 1, 'currency' => 'USD', 'amount' => 9.99], uniqueColumns: ['product_id', 'currency'], updateColumns: ['amount'] ));
Updating Data
// Basic update — returns number of affected rows $affected = await( DB::table('users') ->where('id', 1) ->update(['name' => 'Alice Updated', 'updated_at' => now()]) ); // Atomic increment — UPDATE users SET views = views + 1 $affected = await(DB::table('posts')->where('id', $postId)->increment('views')); // Increment by a custom amount $affected = await(DB::table('products')->where('id', $id)->increment('stock', 10)); // Increment with additional column updates in the same statement $affected = await( DB::table('events') ->where('id', $eventId) ->increment('attendees', 1, ['updated_at' => now()]) ); // Atomic decrement $affected = await(DB::table('products')->where('id', $id)->decrement('stock', 5)); // Decrement with guard condition (won't go below zero in a race) $affected = await( DB::table('products') ->where('id', $id) ->where('stock', '>=', $quantity) ->decrement('stock', $quantity, ['reserved_at' => now()]) );
Deleting Data
// Delete matching rows — returns number of affected rows $deleted = await(DB::table('users')->where('id', 42)->delete()); // Bulk delete $deleted = await(DB::table('sessions')->where('expires_at', '<', now())->delete()); // Soft delete pattern (no native soft-delete support — handle at the application level) await(DB::table('posts')->where('id', $postId)->update(['deleted_at' => now()])); // Scope soft-deleted records out of queries $active = await(DB::table('posts')->whereNull('deleted_at')->get());
Advanced
Processing Large Datasets (Streams vs. Chunks)
When retrieving massive amounts of data, a simple get() will exhaust your server's memory. Hibla offers two distinct architectural approaches to process large datasets: Socket Streaming and Query Chunking.
1. Socket Streaming (stream, each, chunkStream)
Streaming keeps a single query open and reads rows incrementally off the database network socket.
- Pros: Incredible performance. It executes exactly one query and maintains a near-zero memory footprint (~2MB) regardless of table size.
- Cons: It holds the active database connection open until iteration completes. If your processing logic is slow (e.g. sending 100k emails), it can monopolize the connection pool.
// each() - Process row by row. Best for fast operations. await(DB::table('orders')->each(function (object $order) { processOrder($order); // Return false to stop streaming early })); // chunkStream() - Retrieve rows in small arrays. Best for bulk inserts/dispatches. await(DB::table('users')->chunkStream(500, function (array $chunk) { sendBatchEmail($chunk); })); // stream() - Raw iterator access for maximum control $stream = await(DB::table('events')->stream(bufferSize: 200)); foreach ($stream as $row) { handle($row); } $stream->cancel(); // Important: Aborts the query on the database server!
2. Query Chunking (chunk, chunkById)
Chunking executes multiple, independent queries. Between each query, the database connection is returned to the pool.
- Pros: Safe for long-running, slow processing tasks because it doesn't hold a connection open.
- Cons:
chunk()relies on SQLOFFSET, which gets progressively slower on massive tables.
// chunk() - Uses LIMIT and OFFSET under the hood. await(DB::table('products')->chunk(100, function (array $products) { reindexProducts($products); // Return false to stop early })); // chunkById() - AVOID OFFSET PENALTY. Uses `WHERE id > ? LIMIT`. // This is the fastest, safest way to chunk large tables. await(DB::table('users')->chunkById(100, function (array $users) { processUsers($users); })); // chunkById() overriding the ID column and using an alias (critical for JOINs) await(DB::table('users') ->join('orders', 'users.id', '=', 'orders.user_id') ->chunkById(100, function (array $users) { // ... }, column: 'users.id', alias: 'id'));
Transactions
Transactions safely wrap multiple queries. In Hibla, transactions utilize SQL Savepoints automatically, allowing infinite nesting without breaking your database state.
// Auto-managed — commits on success, rolls back automatically on any exception $userId = await( DB::transaction(function ($tx) use ($data) { $id = await($tx->table('users')->insertGetId($data['user'])); await($tx->table('profiles')->insert(['user_id' => $id, ...$data['profile']])); await($tx->table('settings')->insert(['user_id' => $id])); return $id; }) ); // Manual — you control commit and rollback $tx = await(DB::beginTransaction()); try { await($tx->table('accounts')->where('id', $from)->decrement('balance', $amount)); await($tx->table('accounts')->where('id', $to)->increment('balance', $amount)); await($tx->commit()); } catch (\Throwable $e) { await($tx->rollback()); throw $e; } // Nested transactions via savepoints await( DB::transaction(function ($outer) use ($orderData) { await($outer->table('orders')->insert($orderData)); // Inner failure rolls back only the inner work, not the outer transaction await($outer->transaction(function ($inner) use ($orderData) { await($inner->table('inventory')->decrement('stock')); await($inner->table('audit_log')->insert($auditData)); })); }) ); // Savepoints manually $tx = await(DB::beginTransaction()); await($tx->savepoint('before_email')); try { await($tx->table('email_queue')->insert($emailData)); } catch (\Throwable $e) { await($tx->rollbackTo('before_email')); } await($tx->commit()); // Commit and rollback hooks $tx = await(DB::beginTransaction()); $tx->onCommit(fn() => Cache::flush('orders')); $tx->onRollback(fn() => logger()->warning('Order transaction rolled back')); // Bind an existing query builder to an active transaction // Returns a cloned builder that executes on $tx — does not mutate the original $txQuery = DB::table('users')->transacting($tx); await($txQuery->where('id', 1)->update(['status' => 'processing']));
Common Table Expressions
// Simple CTE — pre-filter before the main query $active = await( DB::table('active_users') ->with('active_users', fn($q) => $q->from('users')->where('status', 'active')) ->select('id', 'name', 'email') ->get() ); // WITH active_users AS (SELECT * FROM users WHERE status = ?) // SELECT id, name, email FROM active_users // Chained CTEs — each builds on the previous $report = await( DB::table('final') ->with('raw', fn($q) => $q->from('orders')->where('status', 'completed')) ->with('totals', fn($q) => $q->from('raw')->select('user_id')->selectRaw('SUM(total) as spent')->groupBy('user_id')) ->with('final', fn($q) => $q->from('totals')->where('spent', '>', 500)) ->select('user_id', 'spent') ->orderByDesc('spent') ->get() ); // Recursive CTE — withRecursive() is a readable shorthand for with(..., recursive: true). // Both compile identically; use whichever reads more clearly at the call site. $tree = await( DB::table('category_tree') ->withRecursive('category_tree', function ($q) { return $q ->from('categories') ->select('id', 'name', 'parent_id') ->whereNull('parent_id') // anchor: root nodes ->unionAll(fn($u) => $u ->from('categories') ->select('categories.id', 'categories.name', 'categories.parent_id') ->join('category_tree', 'category_tree.id = categories.parent_id')); }) ->get() );
Pessimistic Locking
Lock clauses are only meaningful inside a database transaction.
// Exclusive lock — no other transaction can read or lock the rows $pdo->beginTransaction(); $order = await( DB::table('orders') ->where('id', $orderId) ->where('status', 'pending') ->lockForUpdate() ->first() ); // ... process order $pdo->commit(); // Skip locked rows — essential for job queue workers so they don't block each other $job = await( DB::table('jobs') ->where('status', 'available') ->orderByDesc('priority') ->oldest() ->limit(1) ->lockForUpdate() ->skipLocked() ->first() ); // Fail immediately if locked $item = await( DB::table('inventory') ->where('id', $itemId) ->lockForUpdate() ->noWait() ->first() ); // Shared lock — others can read but not modify $balance = await( DB::table('accounts') ->where('id', $accountId) ->lockForShare() ->first() );
Debugging
// Get the compiled SQL string (with ? placeholders) $sql = DB::table('users')->where('active', true)->latest()->toSql(); // SELECT * FROM users WHERE active = ? ORDER BY created_at DESC // Get the positional bindings in order $bindings = DB::table('users')->where('active', true)->getBindings(); // [true] // Get interpolated SQL — for human reading only, NEVER execute this $raw = DB::table('users')->where('active', true)->toRawSql(); // SELECT * FROM users WHERE active = '1' ORDER BY created_at DESC // Dump query at any point in the chain and continue building $users = await( DB::table('users') ->where('active', true) ->debug() // prints the query here ->latest() ->limit(10) ->dump() // prints again after the additions ->get() ); // Dump and die — stops execution DB::table('users')->where('active', true)->halt();
Schema Manager
The schema manager is an optional package. Install it only when you need migrations, seeders, or the CLI tooling. The query builder works independently without it.
Installing the Schema Manager
composer require hiblaphp/schema-manager
Initializing
Run once after installation:
./vendor/bin/hibla-db init
This creates three config files in your project root:
hibla-database.php ← Database connections (shared with the query builder)
hibla-migrations.php ← Migration settings
hibla-seeders.php ← Seeder settings
Check the current config resolution status at any time:
./vendor/bin/hibla-db status
For custom config locations, see Custom Config Locations.
Custom Entry Point
By default, all CLI commands are run through ./vendor/bin/hibla-db. If you prefer a shorter command or a project-specific name, you can create your own entry point — a plain file with no extension placed in your project root.
For example, create a file named database (no .php extension):
#!/usr/bin/env php <?php require_once __DIR__ . '/vendor/autoload.php'; use Hibla\SchemaManager\Console\DbSeedCommand; use Symfony\Component\Console\Application; use Hibla\SchemaManager\Console\InitCommand; use Hibla\SchemaManager\Console\PublishTemplatesCommand; use Hibla\SchemaManager\Console\MakeMigrationCommand; use Hibla\SchemaManager\Console\MakeSeederCommand; use Hibla\SchemaManager\Console\MigrateCommand; use Hibla\SchemaManager\Console\MigrateRollbackCommand; use Hibla\SchemaManager\Console\MigrateResetCommand; use Hibla\SchemaManager\Console\MigrateRefreshCommand; use Hibla\SchemaManager\Console\MigrateFreshCommand; use Hibla\SchemaManager\Console\MigrateStatusCommand; use Hibla\SchemaManager\Console\SchemaDumpCommand; use Hibla\SchemaManager\Console\StatusCommand; $application = new Application('Hibla Database', '1.0.0'); $application->addCommand(new InitCommand()); $application->addCommand(new PublishTemplatesCommand()); $application->addCommand(new MakeMigrationCommand()); $application->addCommand(new MakeSeederCommand()); $application->addCommand(new DbSeedCommand()); $application->addCommand(new MigrateCommand()); $application->addCommand(new MigrateRollbackCommand()); $application->addCommand(new MigrateResetCommand()); $application->addCommand(new MigrateRefreshCommand()); $application->addCommand(new MigrateFreshCommand()); $application->addCommand(new MigrateStatusCommand()); $application->addCommand(new SchemaDumpCommand()); $application->addCommand(new StatusCommand()); $application->run();
Despite having no .php extension, this file contains PHP code. The #!/usr/bin/env php line at the top tells your shell to run it with PHP. The file can be named anything you like — database, db, artisan, or whatever fits your project.
Then invoke it with php followed by the filename:
php database migrate php database make:migration create_users_table php database db:seed --class=UserSeeder
On Linux and macOS, you can make the file executable to drop the php prefix entirely:
chmod +x database ./database migrate
The entry point file registers exactly the same commands as the built-in binary. Any flag, option, or
--connectionargument documented in the CLI Reference works identically.
CLI Reference
Full command reference — jump to the sections below for usage examples and options.
| Command | Description |
|---|---|
init |
Scaffold config files |
status |
Show config resolution status |
make:migration <name> |
Generate a migration file |
make:seeder <name> |
Generate a seeder file |
migrate |
Run pending migrations |
migrate:rollback |
Roll back the last batch |
migrate:reset |
Roll back all migrations |
migrate:refresh |
Reset and re-run all migrations |
migrate:fresh |
Drop all tables and re-run from scratch |
migrate:status |
Show run status of each migration |
db:seed |
Run database seeders |
schema:dump |
Dump the current schema to a SQL file |
publish:templates |
Publish pagination templates for customization |
All commands that touch the database accept --connection=<name> to target a specific named connection from hibla-database.php.
Migrations
Creating Migration Files
The migration name is used to auto-detect the operation (create_*_table, add_*_to_*, drop_*):
# Auto-detects CREATE TABLE ./vendor/bin/hibla-db make:migration create_users_table # Auto-detects ALTER TABLE ./vendor/bin/hibla-db make:migration add_avatar_to_users_table # Explicit table or alter ./vendor/bin/hibla-db make:migration create_posts_table --table=posts ./vendor/bin/hibla-db make:migration add_bio_to_users --alter=users # Organize into subdirectories ./vendor/bin/hibla-db make:migration create_orders_table --path=ecommerce # Creates: database/migrations/ecommerce/2024_01_01_000000_create_orders_table.php # Target a specific named connection ./vendor/bin/hibla-db make:migration create_events_table --connection=pgsql
Naming conventions are configured in hibla-migrations.php:
'naming_convention' => 'timestamp', // 2024_01_01_000000_create_users_table.php // or 'naming_convention' => 'sequential', // 0001_create_users_table.php
Writing Migrations
Migrations return an anonymous class extending Migration. All schema methods return promises and must be awaited:
<?php use Hibla\Migrations\Schema\Blueprint; use Hibla\Migrations\Schema\Migration; use function Hibla\await; return new class extends Migration { public function up(): void { await($this->create('users', function (Blueprint $table) { $table->id(); $table->string('name'); $table->string('email')->unique(); $table->string('password'); $table->timestamp('email_verified_at')->nullable(); $table->boolean('active')->default(true); $table->timestamps(); // Table Options (MySQL/MariaDB) $table->engine('InnoDB'); $table->charset('utf8mb4'); $table->collation('utf8mb4_unicode_ci'); })); } public function down(): void { await($this->dropIfExists('users')); } };
Altering a table and modifying columns:
return new class extends Migration { public function up(): void { await($this->table('users', function (Blueprint $table) { // Add new columns $table->string('avatar_url')->nullable(); $table->string('phone', 20)->nullable()->after('email'); // Modify existing columns $table->modifyString('name', 100)->nullable(); $table->modifyInteger('views', unsigned: true)->default(0); // (Available: modifyString, modifyInteger, modifyBigInteger, modifySmallInteger, // modifyTinyInteger, modifyText, modifyDecimal, modifyBoolean) $table->index('phone'); $table->foreign('role_id')->references('id')->on('roles')->cascadeOnDelete(); })); } public function down(): void { await($this->table('users', function (Blueprint $table) { $table->dropForeign(['users_role_id_foreign']); $table->dropIndex(['users_phone_index']); $table->dropColumn(['avatar_url', 'phone']); // Revert modified columns $table->modifyString('name', 255)->nullable(false); })); } };
Pinning a migration to a specific connection:
return new class extends Migration { protected ?string $connection = 'pgsql'; public function up(): void { await($this->create('analytics_events', function (Blueprint $table) { $table->id(); $table->string('event_name'); $table->json('payload'); $table->timestamps(); })); } public function down(): void { await($this->dropIfExists('analytics_events')); } };
Disabling transactions (e.g. for CREATE INDEX CONCURRENTLY on PostgreSQL):
return new class extends Migration { protected bool $withinTransaction = false; public function up(): void { await($this->rawExecute( 'CREATE INDEX CONCURRENTLY idx_users_email ON users (email)' )); } public function down(): void { await($this->rawExecute( 'DROP INDEX CONCURRENTLY IF EXISTS idx_users_email' )); } };
Ad-hoc queries inside a migration. $this->db() returns a full BaseQueryBuilderInterface instance bound to the migration's active transaction:
public function up(): void { await($this->create('roles', function (Blueprint $table) { $table->id(); $table->string('name')->unique(); })); // Seed default data as part of the migration await($this->db('roles')->insertBatch([ ['name' => 'admin'], ['name' => 'editor'], ['name' => 'viewer'], ])); }
Blueprint Column Types
// Auto-increment primary keys $table->id(); // BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY $table->bigIncrements('id'); $table->increments('id'); // INT AUTO_INCREMENT $table->mediumIncrements('id'); $table->smallIncrements('id'); $table->tinyIncrements('id'); // Integers $table->bigInteger('score'); $table->integer('count'); $table->mediumInteger('views'); $table->smallInteger('priority'); $table->tinyInteger('flag'); $table->unsignedBigInteger('user_id'); // common for FK columns $table->unsignedInteger('order_number'); // Strings $table->string('name'); // VARCHAR(255) $table->string('code', 10); // VARCHAR(10) $table->text('body'); $table->mediumText('content'); $table->longText('article'); // Numerics $table->decimal('price', 10, 2); // DECIMAL(10,2) $table->float('ratio', 8, 2); $table->double('score', 8, 4); $table->unsignedDecimal('amount', 10, 2); // Date and time $table->date('birth_date'); $table->dateTime('scheduled_at'); $table->timestamp('verified_at')->nullable(); $table->timestamp('login_at')->timezone('America/New_York'); // Timezone aware $table->timestamps(); // created_at + updated_at, nullable $table->softDeletes(); // deleted_at nullable timestamp $table->softDeletes('removed_at'); // custom column name // Other $table->boolean('active')->default(false); $table->json('options'); $table->enum('status', ['draft', 'published', 'archived']); // PostgreSQL only $table->vector('embedding', 1536); // pgvector column // Geometry $table->point('location'); $table->polygon('boundary'); $table->geometry('shape'); // Column modifiers (chainable) $table->string('bio')->nullable(); $table->integer('order')->default(0); $table->string('country')->after('city'); $table->timestamp('published_at')->useCurrent(); $table->string('slug')->unique(); $table->text('search_body')->fullText(); $table->string('notes')->comment('Internal use only'); // Indexes $table->index('email'); $table->index(['last_name', 'first_name']); // composite $table->unique('email'); $table->unique(['email', 'tenant_id']); $table->fullText('body'); $table->spatialIndex('location'); // PostgreSQL Vector Index (Supports COSINE, L2, or INNER_PRODUCT) $table->vectorIndex('embedding', 'vector_idx', 'COSINE'); // Raw/Custom index expressions $table->rawIndex('CONSTRAINT idx_score UNIQUE (score)', 'idx_score'); // Foreign keys $table->foreignId('user_id')->constrained(); // references users.id $table->foreignId('user_id')->constrained()->cascadeOnDelete(); $table->foreignId('category_id')->constrained('categories')->nullOnDelete(); $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete(); $table->foreign('tenant_id')->references('id')->on('tenants')->restrictOnDelete(); // Drop operations (for alter migrations) $table->dropColumn('avatar_url'); $table->dropColumn(['avatar_url', 'phone']); $table->renameColumn('bio', 'biography'); $table->dropIndex(['users_phone_index']); $table->dropUnique(['users_email_unique']); $table->dropForeign(['users_role_id_foreign']); $table->dropPrimary();
Running Migrations
# Run all pending migrations ./vendor/bin/hibla-db migrate # Run migrations across ALL configured connection pools automatically ./vendor/bin/hibla-db migrate --all # Run only the next N migrations ./vendor/bin/hibla-db migrate --step=3 # Target a specific connection ./vendor/bin/hibla-db migrate --connection=pgsql # Run only migrations in a subdirectory ./vendor/bin/hibla-db migrate --path=ecommerce # Create the database if it doesn't exist yet ./vendor/bin/hibla-db migrate --force
Rolling Back
# Roll back the last batch ./vendor/bin/hibla-db migrate:rollback # Roll back a specific number of migrations ./vendor/bin/hibla-db migrate:rollback --step=5 # Roll back all migrations (prompts for confirmation) ./vendor/bin/hibla-db migrate:reset # Roll back all then re-run all ./vendor/bin/hibla-db migrate:refresh # Drop all tables and re-run from scratch ./vendor/bin/hibla-db migrate:fresh # Skip confirmation ./vendor/bin/hibla-db migrate:fresh --force ./vendor/bin/hibla-db migrate:reset --force
All destructive commands (
migrate:fresh,migrate:reset,migrate:refresh,migrate:rollback) are blocked entirely when Safe Mode is enabled.
Migration Status
# Show all migrations with run status ./vendor/bin/hibla-db migrate:status # Filter to pending only ./vendor/bin/hibla-db migrate:status --pending # Filter to completed only ./vendor/bin/hibla-db migrate:status --ran # Show status for all configured connections ./vendor/bin/hibla-db migrate:status --all
Output shows each migration path, its status (✓ Ran / Pending / ✓ Ran (Pruned)), the batch number it ran in, and the connection it targets.
Schema Dump
For mature projects with many migrations, you can squash all ran migrations into a single SQL file. Fresh environments load from this file directly, skipping potentially hundreds of migration files:
# Dump current schema to database/schema/mysql-schema.sql ./vendor/bin/hibla-db schema:dump # Dump and delete all migration files that have already run ./vendor/bin/hibla-db schema:dump --prune # Dump a specific connection's schema ./vendor/bin/hibla-db schema:dump --connection=pgsql # → database/schema/pgsql-schema.sql
On the next migrate run against a fresh database, the migration system detects the schema file and loads it before running any new migrations. This requires the mysql, psql, or sqlite3 CLI tools to be available in the system PATH; if they are not found, a pure PHP fallback is used automatically.
Seeders
Creating Seeders
./vendor/bin/hibla-db make:seeder UserSeeder # In a subdirectory ./vendor/bin/hibla-db make:seeder testing/UserSeeder # For a specific connection ./vendor/bin/hibla-db make:seeder AnalyticsSeeder --connection=pgsql
Writing Seeders
Seeders return an anonymous class extending Seeder. Use await() for all database calls:
<?php use Hibla\Migrations\Schema\Seeder; use function Hibla\await; return new class extends Seeder { public function run(): void { await($this->db('users')->insertBatch([ ['name' => 'Alice', 'email' => 'alice@example.com', 'active' => true], ['name' => 'Bob', 'email' => 'bob@example.com', 'active' => true], ])); } };
Orchestrating from a root seeder:
You can call other seeders from a root seeder (like DatabaseSeeder.php) using the call() method. This method is highly polymorphic and accepts three types of arguments:
- By File Name: Passes a relative path (great for anonymous classes without autoloading).
- By Class String: Passes the fully qualified class name of an autoloaded seeder.
- By Object Instance: Passes an instantiated seeder object, which is perfect for injecting custom dependencies.
<?php use Hibla\Migrations\Schema\Seeder; use function Hibla\await; return new class extends Seeder { public function run(): void { // 1. By relative file path await($this->call('UserSeeder')); await($this->call('testing/DummyDataSeeder')); // 2. By class-string await($this->call(\Database\Seeders\OrderSeeder::class)); // 3. By object instance (Injecting dependencies!) $apiClient = new CustomApiClient(); await($this->call(new SettingsSeeder($apiClient))); } };
The called seeder inherits the parent connection unless it declares its own. You can use any query builder feature inside a seeder:
public function run(): void { // Idempotent seeding — skip if already seeded $exists = await($this->db('roles')->where('name', 'admin')->exists()); if (! $exists) { await($this->db('roles')->insertBatch([ ['name' => 'admin'], ['name' => 'editor'], ])); } }
Running Seeders
# Run DatabaseSeeder if it exists, otherwise runs all discovered seeders alphabetically ./vendor/bin/hibla-db db:seed # Run a specific seeder by name ./vendor/bin/hibla-db db:seed --class=UserSeeder # Target a named connection ./vendor/bin/hibla-db db:seed --connection=pgsql # Force in safe-mode environments ./vendor/bin/hibla-db db:seed --force
Multiple Connections
Every CLI command accepts --connection to target a named connection. Migration files can also declare their own connection via the $connection property (see Writing Migrations).
To organize migration and seeder files by connection, configure connection_paths in the respective config files:
// hibla-migrations.php 'connection_paths' => [ 'mysql' => 'mysql', 'pgsql' => 'postgres', ], // make:migration --connection=pgsql → database/migrations/postgres/... // hibla-seeders.php 'connection_paths' => [ 'mysql' => 'mysql', 'pgsql' => 'postgres', ],
You can also override the migrations table name and naming convention per connection:
// hibla-migrations.php 'connections' => [ 'pgsql' => [ 'migrations_table' => 'schema_versions', 'naming_convention' => 'sequential', 'timezone' => 'America/New_York', ], ],
Safe Mode
Safe mode prevents destructive commands (migrate:fresh, migrate:reset, migrate:refresh, and migrate:rollback) from running, and also blocks db:seed unless forced. It is configured in hibla-migrations.php and is strongly recommended for production environments:
// hibla-migrations.php 'safe_mode' => env('DB_SAFE_MODE', false),
# .env (production) DB_SAFE_MODE=true
With safe mode enabled:
# HARD BLOCKED — prints an error and exits immediately. ./vendor/bin/hibla-db migrate:fresh ./vendor/bin/hibla-db migrate:reset ./vendor/bin/hibla-db migrate:refresh ./vendor/bin/hibla-db migrate:rollback # Blocked by default, but CAN be forced ./vendor/bin/hibla-db db:seed ./vendor/bin/hibla-db db:seed --force # ALLOWED — Standard forward migrations are not affected ./vendor/bin/hibla-db migrate
NOTE:
--forcedoes NOT override Safe Mode for schema commands. IfDB_SAFE_MODE=trueis set, passing the--forceflag tomigrate:fresh,reset,refresh, orrollbackwill not work. The command will still immediately abort. This is a deliberate design choice to prevent automated CI/CD pipelines or sleepy developers from accidentally wiping a production database. The only command where--forcebypasses Safe Mode isdb:seed.
Here is the Inspiration section, written to be professional, appreciative, and perfectly positioned to go right before your License section:
Inspiration
The design, API, and developer experience of the Hibla Database Ecosystem are heavily inspired by two outstanding open-source projects:
- Laravel's Database Tooling: The fluent query builder syntax, migration blueprints, and CLI output structures are modeled after Laravel's world-class developer experience.
- Knex.js: The asynchronous, promise-driven execution model, native connection pooling paradigms, and standalone CLI flow are inspired by the standard-bearer of JavaScript database tools.
Hibla aims to bring the structural elegance of Laravel and the non-blocking, event-driven power of Knex.js together natively into the modern PHP Fiber runtime.
License
MIT
统计信息
- 总下载量: 0
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 1
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-06-09