-
----
-
-FoxDB is the database layer of the [Webrium](https://github.com/webrium) framework, available as a standalone package. It gives you a fluent query builder for writing SQL without strings, an Eloquent-style ORM for working with your data as objects, a schema builder for managing your database structure in PHP, and a migration system for versioning those changes. All of this runs on top of PDO with no external dependencies beyond the driver itself.
-
----
-
-## What's new in v4
-
-Version 4 is a complete rewrite that adds a full ORM layer and brings FoxDB from a query builder into a proper database toolkit.
-
-- **Eloquent ORM** — define your tables as PHP classes. Get mass assignment protection, automatic timestamps, attribute casting, dirty tracking (only changed columns are saved), and full JSON serialization out of the box.
-- **Relations** — `hasOne`, `hasMany`, `belongsTo`, `belongsToMany`, `hasManyThrough`. Both lazy and eager loading are supported.
-- **Soft Deletes** — mark rows as deleted without actually removing them, and restore them later. The scope is applied automatically, including when the trait is inherited from a parent model.
-- **Schema Builder** — create and modify tables using a fluent Blueprint API instead of writing DDL by hand.
-- **Migrations** — version your schema changes with `up()` / `down()` methods and roll them back whenever you need.
-- **Collection** — a rich wrapper around query results with map, filter, sort, chunk, paginate, and full JSON serialization. Calling `toArray()` on a collection of models correctly uses each model's own serialization, so hidden fields, casts, and loaded relations all behave as expected.
-- **Multi-driver** — MySQL, PostgreSQL, and SQLite all work correctly, with per-driver SQL generation for things like identifier quoting, upserts, and UPDATE syntax.
-- **Query Log & Hooks** — log every query that runs, measure execution time, detect slow queries, and attach `beforeQuery` / `afterQuery` callbacks.
-- **PHPUnit test suite** — unit tests for SQL generation and model logic, plus integration tests that run against all three drivers in CI.
-
----
+A command-line toolkit for the [Webrium](https://github.com/webrium) PHP framework. Provides commands for scaffolding files, managing databases, inspecting logs, and installing plugins.
## Requirements
-- PHP **8.1** or higher
-- PDO extension for your chosen database: `pdo_mysql`, `pdo_pgsql`, or `pdo_sqlite`
-
----
+- PHP 8.1+
+- Symfony Console 6.4+
## Installation
```bash
-composer require webrium/foxdb
+composer require webrium/console
```
---
-## Table of Contents
-
-- [Connection Setup](#connection-setup)
-- [Query Builder](#query-builder)
- - [Fetching rows](#fetching-rows)
- - [WHERE conditions](#where-conditions)
- - [JOIN](#join)
- - [ORDER, GROUP BY, HAVING, LIMIT](#order-group-by-having-limit)
- - [Aggregates](#aggregates)
- - [INSERT](#insert)
- - [UPDATE](#update)
- - [DELETE](#delete)
- - [Pagination](#pagination)
- - [Raw SQL](#raw-sql)
- - [Transactions](#transactions)
- - [Debug helpers](#debug-helpers)
-- [Eloquent ORM](#eloquent-orm)
- - [Defining a Model](#defining-a-model)
- - [Mass assignment](#mass-assignment)
- - [CRUD operations](#crud-operations)
- - [Dirty tracking](#dirty-tracking)
- - [Casts](#casts)
- - [Soft Deletes](#soft-deletes)
- - [Relations](#relations)
- - [Eager Loading](#eager-loading)
- - [Local Scopes](#local-scopes)
- - [Serialization](#serialization)
-- [Collection](#collection)
-- [Schema Builder](#schema-builder)
-- [Migrations](#migrations)
-- [Query Log](#query-log)
-- [Error Handling](#error-handling)
-- [Running Tests](#running-tests)
+## Available Commands
+
+| Command | Description |
+|---|---|
+| `init` | Create the project directory structure |
+| `make:model` | Generate a model file |
+| `make:controller` | Generate a controller file |
+| `make:route` | Generate a route file |
+| `make:migration` | Generate a database migration file |
+| `migrate` | Run, roll back, or inspect database migrations |
+| `call` | Call a method on a controller or model |
+| `db` | Manage databases |
+| `table` | Manage database tables and execute SQL files |
+| `log` | Manage log files |
+| `plugin:install` | Install a plugin |
+| `plugin:update` | Update an installed plugin |
+| `plugin:remove` | Remove an installed plugin |
+| `plugin:list` | List installed plugins |
+| `plugin:info` | Preview a plugin without installing |
---
-## Connection Setup
-
-Before you can do anything, tell FoxDB how to connect to your database. You do this once — typically in your application bootstrap file — by calling `DB::addConnection()` with a configuration array.
+## `init`
-```php
-use Foxdb\DB;
+Creates all standard Webrium project directories.
-DB::addConnection([
- 'driver' => 'mysql', // 'mysql' | 'pgsql' | 'sqlite'
- 'host' => '127.0.0.1',
- 'port' => '3306',
- 'database' => 'my_db',
- 'username' => 'root',
- 'password' => 'secret',
- 'charset' => 'utf8mb4',
- 'throw_exceptions' => true, // default: true
-]);
-```
-
-For SQLite, you only need the path to the database file:
-
-```php
-DB::addConnection([
- 'driver' => 'sqlite',
- 'database' => '/var/data/my_app.sqlite',
-]);
-```
-
-### Multiple connections
-
-If your application talks to more than one database, register each connection under a name and switch between them as needed:
-
-```php
-DB::addConnection([...], 'main');
-DB::addConnection([...], 'analytics');
-
-DB::use('analytics'); // subsequent calls use this connection
-DB::use('main'); // switch back
-
-DB::connection('analytics'); // get the raw Connection instance
+```bash
+php webrium init
```
-You can also assign a specific connection to a Model by setting `$connection` on the model class (see [Defining a Model](#defining-a-model)).
-
---
-## Query Builder
-
-The query builder lets you construct and execute SQL queries using a fluent PHP API. Every query starts with `DB::table('table_name')`, which returns a `Builder` instance. You chain methods to build the query, and finish with a terminal method (`get`, `first`, `insert`, `update`, etc.) to execute it.
-
-All user-supplied values are passed as PDO bindings — FoxDB never interpolates values directly into SQL, so you are protected from SQL injection without any extra sanitization on your part.
-
-### Fetching rows
-
-The most common operation is fetching rows from a table:
-
-```php
-// Get all rows — returns a Collection of stdClass objects
-$users = DB::table('users')->get();
-
-foreach ($users as $user) {
- echo $user->name;
-}
-
-// Get the first matching row — returns stdClass or false if nothing matches
-$user = DB::table('users')->where('email', 'ali@example.com')->first();
-
-if ($user) {
- echo $user->name;
-}
-
-// Get a single column value from the first matching row
-$email = DB::table('users')->where('id', 5)->value('email');
-
-// Get a flat array of values from a single column
-$names = DB::table('users')->pluck('name');
-// → ['Alice', 'Bob', 'Carol']
-
-// Get an array keyed by another column — useful for dropdowns
-$nameById = DB::table('users')->pluck('name', 'id');
-// → [1 => 'Alice', 2 => 'Bob']
-
-// Find a row by its primary key
-$user = DB::table('users')->find(5);
-
-// Select only certain columns
-$users = DB::table('users')->select('id', 'name', 'email')->get();
-
-// Select a raw expression
-$stats = DB::table('orders')
- ->selectRaw('COUNT(*) as total, SUM(amount) as revenue')
- ->where('status', 'paid')
- ->first();
-
-// Remove duplicate rows
-$countries = DB::table('users')->distinct()->pluck('country');
-```
-
-When you need to process a very large number of rows without loading them all into memory at once, use `chunk` or `each`:
-
-```php
-// Process 200 rows at a time
-DB::table('users')->orderBy('id')->chunk(200, function ($users) {
- foreach ($users as $user) {
- // process each user
- }
-
- // Return false from the callback to stop chunking early
-});
-
-// Simpler iteration with each()
-DB::table('users')->orderBy('id')->each(function ($user) {
- // called once per row
-});
-```
-
-### WHERE conditions
-
-FoxDB supports every common SQL condition. The basic form is `where(column, value)` which assumes `=`, or `where(column, operator, value)` for other comparisons:
-
-```php
-->where('active', 1) // WHERE active = 1
-->where('age', '>', 18) // WHERE age > 18
-->where('role', '!=', 'banned') // WHERE role != 'banned'
-->orWhere('role', 'admin') // OR role = 'admin'
-->whereNot('status', 'deleted') // WHERE status != 'deleted'
-```
-
-**IN / NOT IN** — check if a column value is in a list:
-
-```php
-->whereIn('id', [1, 2, 3])
-->whereNotIn('status', ['banned', 'pending'])
-->orWhereIn('role', ['admin', 'mod'])
-```
-
-**BETWEEN** — check if a value falls in a range:
-
-```php
-->whereBetween('age', 18, 65)
-->whereNotBetween('score', 0, 10)
-```
-
-**NULL checks:**
-
-```php
-->whereNull('deleted_at') // only rows where deleted_at IS NULL
-->whereNotNull('verified_at') // only rows where verified_at is set
-```
-
-**Raw expressions** — when you need SQL that the builder cannot produce:
-
-```php
-->whereRaw('YEAR(created_at) = ? AND MONTH(created_at) = ?', [2024, 3])
-```
-
-**Date helpers** — compare against parts of a datetime column:
-
-```php
-->whereDate('created_at', '=', '2024-01-15') // full date match
-->whereYear('created_at', '=', 2024)
-->whereMonth('created_at', '=', 1)
-->whereDay('created_at', '=', 15)
-->whereTime('created_at', '>', '08:00:00')
-```
-
-**Column-to-column comparison:**
-
-```php
-->whereColumn('updated_at', '>', 'created_at')
-```
-
-**Grouped conditions** — wrap a group of conditions in parentheses by passing a closure:
-
-```php
-// WHERE (role = 'admin' OR role = 'mod') AND active = 1
-DB::table('users')
- ->where(function ($q) {
- $q->where('role', 'admin')->orWhere('role', 'mod');
- })
- ->where('active', 1)
- ->get();
-```
-
-**Subquery conditions:**
-
-```php
-// WHERE EXISTS (SELECT 1 FROM orders WHERE orders.user_id = users.id)
-->whereExists(function ($q) {
- $q->table('orders')->whereColumn('user_id', 'users.id');
-})
-```
-
-**Shorthand methods** — for common patterns, FoxDB provides shorter alternatives:
-
-```php
-->is('active', 1) // same as where('active', 1)
-->true('active') // same as where('active', 1)
-->false('active') // same as where('active', 0)
-->null('deleted_at') // same as whereNull('deleted_at')
-->notNull('email') // same as whereNotNull('email')
-->in('id', [1,2,3]) // same as whereIn('id', [...])
-->notIn('id', [4,5]) // same as whereNotIn('id', [...])
-->like('name', '%ali%') // WHERE name LIKE '%ali%'
-->and('age', '>', 18) // same as where()
-->or('role', 'admin') // same as orWhere()
-```
-
-### JOIN
-
-```php
-// INNER JOIN — only rows that have a match in both tables
-DB::table('users')
- ->join('orders', 'orders.user_id', '=', 'users.id')
- ->select('users.name', 'orders.total', 'orders.status')
- ->get();
-
-// LEFT JOIN — all users, even those with no orders
-DB::table('users')
- ->leftJoin('orders', 'orders.user_id', '=', 'users.id')
- ->select('users.name', 'orders.total')
- ->get();
-
-->rightJoin('table', 'a', '=', 'b')
-->crossJoin('tags')
-
-// Join against a subquery
-->joinSub($subQuery, 'alias', 'alias.user_id', '=', 'users.id')
-```
-
-### ORDER, GROUP BY, HAVING, LIMIT
-
-```php
-// Sort results
-->orderBy('name') // ASC by default
-->orderBy('created_at', 'desc')
-->orderByDesc('score') // shorthand for desc
-->latest() // ORDER BY created_at DESC
-->oldest() // ORDER BY created_at ASC
-->inRandomOrder() // ORDER BY RAND() — useful for random picks
-
-// Sort by multiple columns
-->orderBy('role')->orderBy('name', 'desc')
-
-// Group results and filter groups
-->groupBy('country')
-->having('total_users', '>', 100)
-->havingRaw('COUNT(*) > 100')
-
-// Limit the number of results
-->limit(10)
-->offset(20)
-->take(10)->skip(20) // aliases — take and skip are identical to limit and offset
-```
-
-### Aggregates
-
-Aggregate methods execute the query immediately and return a single value:
+## `make:model`
-```php
-$count = DB::table('users')->count();
-$active = DB::table('users')->where('active', 1)->count();
-$revenue = DB::table('orders')->where('status', 'paid')->sum('total');
-$avgScore = DB::table('users')->avg('score');
-$lowest = DB::table('products')->min('price');
-$highest = DB::table('products')->max('price');
-
-// Check if any matching row exists
-$exists = DB::table('users')->where('email', 'ali@example.com')->exists(); // bool
-```
-
-### INSERT
-
-```php
-// Insert a single row
-DB::table('users')->insert([
- 'name' => 'Ali',
- 'email' => 'ali@example.com',
-]);
-
-// Insert and get the auto-increment ID back
-$id = DB::table('users')->insertGetId([
- 'name' => 'Ali',
- 'email' => 'ali@example.com',
-]);
-
-// Insert multiple rows in one query
-DB::table('users')->insertBatch([
- ['name' => 'Ali', 'email' => 'ali@example.com'],
- ['name' => 'Sara', 'email' => 'sara@example.com'],
- ['name' => 'Reza', 'email' => 'reza@example.com'],
-]);
-```
+Generates a model file in the models directory. Without `--table`, creates a simple model. With `--table`, creates a database-connected model.
-### UPDATE
-
-```php
-// Update matching rows — returns the number of affected rows
-$affected = DB::table('users')
- ->where('id', 1)
- ->update(['name' => 'New Name', 'updated_at' => date('Y-m-d H:i:s')]);
-
-// Increment or decrement a numeric column
-DB::table('users')->where('id', 1)->increment('login_count');
-DB::table('users')->where('id', 1)->increment('score', 10);
-DB::table('users')->where('id', 1)->decrement('credits', 5);
-
-// You can also update other columns at the same time
-DB::table('users')->where('id', 1)->increment('score', 10, [
- 'last_activity' => date('Y-m-d H:i:s'),
-]);
-
-// Update if found, insert if not
-DB::table('settings')->updateOrInsert(
- ['key' => 'theme'], // lookup condition
- ['value' => 'dark'] // value to set
-);
+```bash
+php webrium make:model [--table=
] [--no-plural] [--force]
```
-### DELETE
-
-```php
-// Delete matching rows
-DB::table('users')->where('id', $id)->delete();
+| Argument / Option | Description |
+|---|---|
+| `Name` | Model class name (e.g. `User`) |
+| `--table, -t` | Database table name. If omitted, the name is auto-converted to snake_case and pluralized |
+| `--no-plural` | Prevent automatic pluralization of the table name |
+| `--force, -f` | Overwrite if the file already exists |
-// Delete with multiple conditions
-DB::table('sessions')
- ->where('user_id', $userId)
- ->where('created_at', 'delete();
-
-// Remove all rows from the table
-DB::table('cache')->truncate();
-```
+```bash
+# DB model with explicit table name
+php webrium make:model User --table=users
-### Pagination
+# DB model — table name auto-generated as "users"
+php webrium make:model User -t
-Pagination is a common need in any application that displays lists. FoxDB handles the total count, offset, and metadata for you:
+# Simple model (no DB)
+php webrium make:model UserHelper
-```php
-$page = (int) ($_GET['page'] ?? 1);
-$result = DB::table('posts')
- ->where('published', 1)
- ->orderBy('created_at', 'desc')
- ->paginate(15, $page);
+# DB model — table stays "status" instead of "statuses"
+php webrium make:model Status -t --no-plural
```
-The returned object has these properties:
-
-|
- Property
-|
- Type
-|
- Description
-|
-|
----
-|
----
-|
---
-|
-|
-`total`
-|
- int
-|
- Total number of matching rows
-|
-|
-`per_page`
-|
- int
-|
- Rows per page
-|
-|
-`current_page`
-|
- int
-|
- The page you requested
-|
-|
-`last_page`
-|
- int
-|
- Total number of pages
-|
-|
-`from`
-|
- int
-|
- Row number of the first result on this page
-|
-|
-`to`
-|
- int
-|
- Row number of the last result on this page
-|
-|
-`data`
-|
- Collection
-|
- The rows for this page
-|
-
-```php
-// Use in an API response
-return [
- 'meta' => [
- 'total' => $result->total,
- 'current_page' => $result->current_page,
- 'last_page' => $result->last_page,
- ],
- 'data' => $result->data->toArray(),
-];
-```
-
-### Raw SQL
-
-When you need to run SQL that the builder cannot express, you can execute raw statements directly:
-
-```php
-// Select — returns array of stdClass
-$rows = DB::select('SELECT * FROM users WHERE active = ? AND age > ?', [1, 18]);
-
-// Select a single row
-$user = DB::selectOne('SELECT * FROM users WHERE id = ?', [$id]);
-
-// Insert
-DB::insert('INSERT INTO logs (level, message) VALUES (?, ?)', ['info', 'User logged in']);
-
-// Insert and get the new ID
-$id = DB::insertGetId('INSERT INTO users (name) VALUES (?)', ['Ali']);
-
-// Update
-$affected = DB::update('UPDATE users SET active = ? WHERE last_login < ?', [0, '2023-01-01']);
-
-// Delete
-$deleted = DB::delete('DELETE FROM logs WHERE created_at < ?', ['2023-01-01']);
-
-// Any statement (DDL, etc.)
-DB::statement('ALTER TABLE users ADD COLUMN bio TEXT NULL');
-
-// Raw expression inside a query
-$stats = DB::table('products')
- ->select(DB::raw('category_id, COUNT(*) as count, AVG(price) as avg_price'))
- ->groupBy('category_id')
- ->having(DB::raw('COUNT(*)'), '>', 5)
- ->get();
-```
-
-### Transactions
-
-Transactions let you group multiple operations so that either all succeed or all are rolled back. The easiest way is to pass a closure to `DB::transaction()` — FoxDB will automatically commit if the closure returns normally, or roll back if it throws:
-
-```php
-DB::transaction(function () use ($fromId, $toId, $amount) {
- DB::table('accounts')->where('id', $fromId)->decrement('balance', $amount);
- DB::table('accounts')->where('id', $toId)->increment('balance', $amount);
- DB::table('transfers')->insert([
- 'from_id' => $fromId,
- 'to_id' => $toId,
- 'amount' => $amount,
- ]);
-});
-```
-
-If you need more control, you can manage the transaction manually:
-```php
-DB::beginTransaction();
+## `make:controller`
-try {
- // ... your operations ...
- DB::commit();
-} catch (\Throwable $e) {
- DB::rollBack();
- throw $e;
-}
+Generates a controller file in the controllers directory. Automatically appends `Controller` to the name if not already present.
-DB::inTransaction(); // bool — check if currently inside a transaction
+```bash
+php webrium make:controller [--namespace=] [--force]
```
-### Debug helpers
-
-When you need to inspect the SQL that FoxDB is generating, these methods let you do so without running the query (or while running it):
-
-```php
-// See the SQL and bindings without executing
-$sql = DB::table('users')->where('active', 1)->orderBy('name')->toSql();
-$bindings = DB::table('users')->where('active', 1)->orderBy('name')->getBindings();
-
-// Dump SQL and bindings to output, then continue execution
-DB::table('users')->where('active', 1)->dump()->get();
+| Argument / Option | Description |
+|---|---|
+| `Name` | Controller name (e.g. `User` → `UserController`) |
+| `--namespace` | Custom namespace (default: `App\Controllers`) |
+| `--force, -f` | Overwrite if the file already exists |
-// Dump SQL and bindings to output, then stop (useful during development)
-DB::table('users')->where('active', 1)->dd();
+```bash
+php webrium make:controller User
+php webrium make:controller Admin --namespace="App\Controllers\Admin"
```
---
-## Eloquent ORM
-
-The ORM layer lets you work with your database tables as PHP classes. Each table maps to a Model class, and each row in that table becomes an instance of that class. Instead of writing queries everywhere, you interact with your data through objects.
-
-### Defining a Model
-
-Create a class that extends `Foxdb\Eloquent\Model`. At minimum you only need the class — FoxDB will infer the table name from it. Everything else is optional:
-
-```php
-use Foxdb\Eloquent\Model;
-
-class User extends Model
-{
- // The database table. If not set, FoxDB auto-derives it:
- // User → users, UserProfile → user_profiles (snake_case + plural s)
- protected string $table = 'users';
-
- // The primary key column. Defaults to 'id'.
- protected string $primaryKey = 'id';
-
- // Columns that may be set via create() or fill().
- // Only columns listed here can be mass-assigned.
- protected array $fillable = ['name', 'email', 'age', 'is_active'];
-
- // Alternatively, use $guarded to blocklist instead of allowlist.
- // An empty guarded array means everything is allowed.
- // protected array $guarded = [];
-
- // Columns excluded from toArray() and toJson() output.
- // Use this for passwords, tokens, and other sensitive fields.
- protected array $hidden = ['password', 'remember_token'];
-
- // Set to false if the table does not have created_at / updated_at columns.
- protected bool $timestamps = true;
-
- // Automatically cast column values to PHP types on read.
- protected array $casts = [
- 'is_active' => 'bool',
- 'age' => 'int',
- 'score' => 'float',
- 'settings' => 'array', // stored as JSON, returned as array
- 'born_at' => 'datetime', // returned as a DateTime object
- ];
+## `make:route`
- // Use a specific named connection instead of the default.
- protected ?string $connection = null;
-}
-```
-
-### Mass assignment
-
-Mass assignment means setting multiple attributes at once via `create()` or `fill()`. FoxDB protects you from accidentally setting columns you did not intend to expose — for example, an `is_admin` field that a user might inject through a form.
-
-You control this with two properties:
-
-- **`$fillable`** — an allowlist. Only these columns can be mass-assigned.
-- **`$guarded`** — a blocklist. Everything except these columns can be mass-assigned. Set it to `[]` to allow all columns.
-
-```php
-// $fillable = ['name', 'email'] — 'role' is blocked
-User::create(['name' => 'Ali', 'email' => 'a@b.com', 'role' => 'admin']);
-// role is silently ignored
-
-// If you need to bypass the guard (e.g. in a seeder), use forceFill()
-$user = new User();
-$user->forceFill(['name' => 'Ali', 'role' => 'admin'])->save();
-```
-
-### CRUD operations
-
-**Creating records:**
-
-```php
-// create() fills, saves, and returns the new model
-$user = User::create(['name' => 'Ali', 'email' => 'ali@example.com', 'age' => 25]);
-echo $user->id; // the auto-increment ID from the database
-echo $user->created_at; // set automatically
-
-// Alternatively, use new + save()
-$user = new User();
-$user->name = 'Ali';
-$user->email = 'ali@example.com';
-$user->save();
-```
-
-**Reading records:**
-
-```php
-// All rows — returns a Collection
-$users = User::all();
-
-// By primary key
-$user = User::find(1); // returns User or null
-$user = User::findOrFail(1); // returns User or throws ModelNotFoundException
-
-// First matching row
-$user = User::where('email', 'ali@example.com')->first();
-$user = User::firstWhere('email', 'ali@example.com'); // shorthand
+Generates a route file in the routes directory.
-// You can chain any Builder method
-$admins = User::where('role', 'admin')
- ->where('active', 1)
- ->orderBy('name')
- ->get();
-
-// Aggregates
-$count = User::where('active', 1)->count();
-$avg = User::avg('score');
-
-// Check existence
-$exists = User::exists(['email' => 'ali@example.com']); // bool
-```
-
-**Updating records:**
-
-```php
-// Change attributes and call save() — only the changed columns are written
-$user = User::findOrFail(1);
-$user->name = 'New Name';
-$user->save();
-
-// Mass update via query — affects all matching rows
-User::where('active', 0)->update(['score' => 0]);
-```
-
-**Deleting records:**
-
-```php
-// Delete a single model instance
-$user = User::findOrFail(1);
-$user->delete();
-
-// Delete all rows matching a condition
-User::where('created_at', 'delete();
-```
-
-**Reloading from the database:**
-
-```php
-// fresh() returns a new instance fetched from the DB, leaving the original untouched
-$fresh = $user->fresh();
-
-// refresh() updates the current instance in place
-$user->refresh();
+```bash
+php webrium make:route [--force]
```
-### Dirty tracking
-
-FoxDB tracks which attributes have changed since the model was last loaded or saved. This is how it knows to only include changed columns in an UPDATE statement:
-
-```php
-$user = User::find(1); // loaded: name = 'Ali'
+| Argument / Option | Description |
+|---|---|
+| `Name` | Route file name (e.g. `Api` → `Api.php`) |
+| `--force, -f` | Overwrite if the file already exists |
-$user->isDirty(); // false — nothing has changed yet
-
-$user->name = 'New Name';
-
-$user->isDirty(); // true
-$user->isDirty('name'); // true
-$user->isDirty('email'); // false — email was not changed
-
-$user->getDirty(); // ['name' => 'New Name']
-
-$user->save(); // UPDATE users SET name = ? WHERE id = ?
- // email is NOT included in the query
+```bash
+php webrium make:route Api
+php webrium make:route Web --force
```
-### Casts
-
-Casts tell FoxDB how to convert a raw database value (always a string or null from PDO) into a proper PHP type when you read an attribute. The cast is applied automatically — you never have to convert values manually.
-
-|
- Cast type
-|
- Aliases
-|
- What you get
-|
-|
----
-|
----
-|
---
-|
-|
-`int`
-|
-`integer`
-|
- PHP
-`int`
-|
-|
-`float`
-|
-`double`
-,
-`real`
-|
- PHP
-`float`
-|
-|
-`bool`
-|
-`boolean`
-|
- PHP
-`bool`
-|
-|
-`string`
-|
- —
-|
- PHP
-`string`
-|
-|
-`array`
-|
-`json`
-|
- PHP
-`array`
- — decoded from JSON on read, encoded back to JSON on write
-|
-|
-`object`
-|
- —
-|
-`stdClass`
- — decoded from JSON
-|
-|
-`datetime`
-|
-`date`
-|
-`DateTime`
- object
-|
-|
-`immutable_datetime`
-|
- —
-|
-`DateTimeImmutable`
- object
-|
-
-```php
-class User extends Model
-{
- protected array $casts = [
- 'is_active' => 'bool',
- 'age' => 'int',
- 'score' => 'float',
- 'settings' => 'array',
- 'born_at' => 'datetime',
- ];
-}
-
-$user = User::find(1);
-
-$user->is_active; // true or false, not "1" or "0"
-$user->age; // 25 (int), not "25" (string)
-$user->settings; // ['theme' => 'dark', 'lang' => 'fa'] — decoded from JSON
-$user->born_at; // DateTime object — can call ->format(), ->diff(), etc.
-
-// Casts work in reverse on write — the array is JSON-encoded before saving
-$user->settings = ['theme' => 'light'];
-$user->save(); // stores '{"theme":"light"}' in the database
-```
-
-### Soft Deletes
-
-Sometimes you want to "delete" a record without actually removing it from the database — so you can restore it later, or keep a history of what was deleted. FoxDB supports this via the `HasSoftDeletes` trait.
-
-When you call `delete()` on a model with soft deletes, FoxDB sets a `deleted_at` timestamp instead of issuing `DELETE`. All subsequent queries automatically exclude soft-deleted rows, so they are invisible to the rest of your application unless you explicitly ask for them.
-
-Your table needs a nullable `deleted_at` column (use `$table->softDeletes()` in your migration).
-
-```php
-use Foxdb\Eloquent\Concerns\HasSoftDeletes;
-
-class Post extends Model
-{
- use HasSoftDeletes;
-}
-```
-```php
-$post = Post::find(1);
-$post->delete(); // sets deleted_at — the row stays in the database
-
-Post::find(1); // returns null — soft-deleted rows are excluded by default
-Post::count(); // does NOT count soft-deleted rows
-
-$post->trashed(); // true — check if this instance has been soft-deleted
-
-// Include soft-deleted rows in a query
-$all = Post::withTrashed()->get();
-$all = Post::withTrashed()->find(1); // returns the soft-deleted post
-
-// Query only the soft-deleted rows
-$deleted = Post::onlyTrashed()->get();
-
-// Restore a soft-deleted record
-Post::withTrashed()->find(1)->restore(); // clears deleted_at
-```
-
-Soft deletes also work when `HasSoftDeletes` is applied on a parent model and inherited by a subclass:
-
-```php
-class BaseModel extends Model
-{
- use HasSoftDeletes;
-}
-
-class Post extends BaseModel
-{
- protected string $table = 'posts';
-}
-
-// The scope is applied correctly — Post inherits from BaseModel
-Post::find(1); // still excludes soft-deleted rows
-```
-
-### Relations
-
-Relations describe how your tables are connected. You define them as methods on your model that return a relation object. FoxDB then uses these to build the correct JOIN or subquery automatically.
-
-**HasMany** — one user has many posts (`posts.user_id` points to `users.id`):
-
-```php
-class User extends Model
-{
- public function posts(): HasMany
- {
- // hasMany(related model, foreign key on related table, local key)
- return $this->hasMany(Post::class, 'user_id', 'id');
- }
-}
-```
+## `make:migration`
-**HasOne** — one user has one profile:
+Generates a timestamped migration file in `database/migrations`. Builds on top of [`webrium/foxdb`](https://github.com/webrium/foxdb)'s migration system (`Foxdb\Migrations\Migration`, `Schema`, `Blueprint`).
-```php
-public function profile(): HasOne
-{
- return $this->hasOne(Profile::class, 'user_id', 'id');
-}
+```bash
+php webrium make:migration [--table=
] [--force]
```
-**BelongsTo** — the post knows which user it belongs to (`posts.user_id` → `users.id`):
-
-```php
-class Post extends Model
-{
- public function author(): BelongsTo
- {
- // belongsTo(related model, foreign key on THIS table, owner key on related table)
- return $this->belongsTo(User::class, 'user_id', 'id');
- }
-}
-```
+| Argument / Option | Description |
+|---|---|
+| `name` | Migration name, e.g. `create_posts_table` or `add_status_to_posts_table` |
+| `--table, -t` | Explicit table name. If omitted, it's inferred from the migration name |
+| `--force, -f` | Allow generating another migration with the same descriptive name |
-**BelongsToMany** — users can have many roles, roles can belong to many users, through a pivot table:
+The generated stub depends on the naming convention used:
-```php
-public function roles(): BelongsToMany
-{
- // belongsToMany(related, pivot table, FK for this model, FK for related model)
- return $this->belongsToMany(Role::class, 'user_role', 'user_id', 'role_id');
-}
-```
+- **`create_..._table`** → uses the *create* stub, with `Schema::create()` already wired up and a ready-to-run `id()` + `timestamps()` example.
+- **`add_..._to_..._table`** / **`remove_..._from_..._table`** → uses the *update* stub, with an empty `Schema::table()` block in both `up()` and `down()` for you to fill in.
+- Anything else falls back to the *create* stub.
-**HasManyThrough** — get all comments on a user's posts, without going through Post:
-
-```php
-public function comments(): HasManyThrough
-{
- return $this->hasManyThrough(
- Comment::class, // the final model you want
- Post::class, // the intermediate model
- 'user_id', // FK on posts pointing to users
- 'post_id', // FK on comments pointing to posts
- 'id', // local key on users
- 'id', // local key on posts
- );
-}
-```
+In every case the table name is inferred automatically from the migration name, unless `--table` is given explicitly.
-**Lazy loading** — relations are loaded the first time you access them and then cached on the instance:
+```bash
+# Create stub — Schema::create('posts', ...) is pre-filled
+php webrium make:migration create_posts_table
-```php
-$user = User::find(1);
+# Update stub — Schema::table('posts', ...) with an empty body
+php webrium make:migration add_status_to_posts_table
+php webrium make:migration remove_legacy_id_from_posts_table
-$posts = $user->posts; // runs a query, returns Collection
-$posts = $user->posts; // uses the cached result — no second query
+# Explicit table name, useful when the migration name doesn't follow either convention
+php webrium make:migration setup_indexes --table=posts
-$author = $post->author; // User|null
-$profile = $user->profile; // Profile|null
+# Allow a duplicate descriptive name (creates a second, separately timestamped file)
+php webrium make:migration create_posts_table --force
```
-**Pivot methods for BelongsToMany:**
-
-```php
-// Add a role to a user
-$user->roles()->attach(3);
-$user->roles()->attach([3, 5, 7]);
-
-// Add with data on the pivot row
-$user->roles()->attach(3, ['granted_at' => date('Y-m-d')]);
-
-// Remove a role
-$user->roles()->detach(3);
-$user->roles()->detach(); // remove all
-
-// Sync — attach the given IDs and detach everything else
-$user->roles()->sync([3, 5]);
-
-// Include pivot columns when loading the relation
-$roles = $user->roles()->withPivot('granted_at', 'expires_at')->get();
-echo $roles->first()->pivot->granted_at;
-```
+---
-**BelongsTo helpers:**
+## `migrate`
-```php
-// Set the foreign key by passing the related model (does not save automatically)
-$post->author()->associate($user);
-$post->save();
+Runs database migrations from `database/migrations` using [`webrium/foxdb`](https://github.com/webrium/foxdb)'s `Migrator`. Tracks applied migrations in a `migrations` table, batched the same way per run so a whole batch can be rolled back together.
-// Clear the foreign key
-$post->author()->dissociate();
-$post->save();
+```bash
+php webrium migrate [] [--step=] [--connection=] [--force]
```
-### Eager Loading
+| Action | Description |
+|---|---|
+| `run` *(default)* | Apply all pending migrations |
+| `rollback` | Roll back the last batch (or `--step` migrations) |
+| `reset` | Roll back every migration that has been run |
+| `refresh` | Roll back everything, then run all migrations again |
+| `status` | Show which migrations have run, and in which batch |
-The problem with lazy loading is that if you load 100 users and then access `$user->posts` for each one, you end up running 101 queries — one for the users and one per user for their posts. This is the N+1 problem.
-
-Eager loading solves this by loading all the related data in one additional query:
-
-```php
-// Without eager loading — runs 1 + N queries
-$users = User::all();
-foreach ($users as $user) {
- echo $user->posts->count(); // query per user!
-}
-
-// With eager loading — runs exactly 2 queries
-$users = User::with('posts')->get();
-foreach ($users as $user) {
- echo $user->posts->count(); // no query — already loaded
-}
-```
+| Option | Description |
+|---|---|
+| `--step` | Limit `run`/`rollback` to a specific number of migrations |
+| `--connection, -c` | Run against a named connection instead of the default one |
+| `--force, -f` | Skip the confirmation prompt for `reset`/`refresh` |
-You can eager-load multiple relations at once:
+```bash
+# Apply all pending migrations
+php webrium migrate
+php webrium migrate run
-```php
-$users = User::with('posts', 'profile', 'roles')->get();
-```
+# Show migration status
+php webrium migrate status
-You can also constrain what gets loaded — for example, only published posts:
+# Roll back the most recent batch
+php webrium migrate rollback
-```php
-$users = User::with([
- 'posts' => fn($query) => $query->where('published', 1)->orderBy('created_at', 'desc')
-])->get();
-```
+# Roll back only the last 2 migrations
+php webrium migrate rollback --step=2
-Eager-loaded relations are included in `toArray()` output automatically:
+# Roll back everything, with confirmation
+php webrium migrate reset
-```php
-$data = User::with('posts')->get()->toArray();
-// $data[0]['posts'] → array of post arrays
-```
+# Roll back everything, skipping the confirmation prompt
+php webrium migrate reset --force
-### Local Scopes
-
-Local scopes let you define reusable query conditions on your model. Define a method prefixed with `scope` and it becomes chainable as a static call without the prefix:
-
-```php
-class User extends Model
-{
- // Scope to filter only active users
- public function scopeActive(Builder $q): Builder
- {
- return $q->where('is_active', 1);
- }
-
- // Scope with a parameter
- public function scopeRole(Builder $q, string $role): Builder
- {
- return $q->where('role', $role);
- }
-
- // Scope for recent records
- public function scopeRecent(Builder $q, int $days = 7): Builder
- {
- return $q->where('created_at', '>=', date('Y-m-d', strtotime("-{$days} days")));
- }
-}
-```
+# Roll back and re-run all migrations
+php webrium migrate refresh --force
-```php
-// Use the scope — drop the 'scope' prefix and call as static
-User::active()->get();
-User::role('admin')->get();
-User::recent(30)->get();
-
-// Scopes are fully chainable with each other and with other Builder methods
-User::active()
- ->role('mod')
- ->recent()
- ->orderBy('name')
- ->paginate(20, $page);
+# Run against a non-default connection
+php webrium migrate --connection=secondary
```
-### Serialization
+Each migration runs inside its own database transaction. If a migration fails, `migrate` stops and reports it — earlier migrations in the same run stay applied, matching the underlying `Migrator::run()` behavior.
-When you want to convert a model — or a collection of models — to an array or JSON (for an API response, for example), use `toArray()` or `toJson()`. These methods respect your `$hidden` fields, apply all `$casts`, and include any loaded relations.
-
-```php
-$user = User::with('posts')->find(1);
-
-$arr = $user->toArray(); // ['id' => 1, 'name' => 'Ali', 'posts' => [...], ...]
-$json = $user->toJson(); // same data as a JSON string
-$json = (string) $user; // identical to toJson()
-
-json_encode($user); // also works — Model implements JsonSerializable
-```
-
-For a collection:
+---
-```php
-$users = User::with('posts')->get();
+## `call`
-$arr = $users->toArray(); // array of arrays — correct
-$json = json_encode($users); // correct
+Calls a method on a controller or model class directly from the terminal.
-// Use in an API response
-return ['ok' => true, 'users' => $users->toArray()];
+```bash
+php webrium call [--params=] [--model] [--namespace=]
```
-> **Important:** Do not use `(array) $model` to convert a model to an array. PHP's object cast exposes internal protected properties with null-byte prefixed keys (`\u0000*\u0000table`, etc.), which will corrupt your JSON output. Always use `->toArray()`.
-
----
+| Argument / Option | Description |
+|---|---|
+| `Class@Method` | Class and method name (e.g. `UserController@index`) |
+| `--params, -p` | JSON array of arguments passed to the method (default: `[]`) |
+| `--model, -m` | Target a model instead of a controller |
+| `--namespace` | Custom namespace (default: `App\Controllers` or `App\Models`) |
-## Collection
-
-`Collection` wraps the array of rows returned by `get()` and most Model query methods. It implements `ArrayAccess`, `Countable`, `IteratorAggregate`, and `JsonSerializable`, so you can treat it like an array in most situations while also having a rich set of transformation methods. All transformation methods return a **new** Collection and leave the original unchanged.
-
-```php
-$users = User::all(); // Collection
-
-// Basic access
-$users->count();
-$users->isEmpty();
-$users->isNotEmpty();
-$users->first();
-$users->first(fn($u) => $u->role === 'admin'); // first matching
-$users->last();
-$users->get(2); // item at index 2, null if missing
-$users->contains('role', 'admin');
-$users->contains(fn($u) => $u->age > 18);
-
-// Iteration — works like a normal array
-foreach ($users as $user) {
- echo $user->name;
-}
-$users[0]; // ArrayAccess read
-
-// Filtering and transformation
-$active = $users->filter(fn($u) => $u->active);
-$inactive = $users->reject(fn($u) => $u->active); // inverse of filter
-$names = $users->map(fn($u) => (object)['name' => strtoupper($u->name)]);
-
-$users->each(fn($u, $index) => processUser($u));
-// Return false from the callback to stop early
-
-// Sorting
-$byName = $users->sortBy('name');
-$byScore = $users->sortByDesc('score');
-
-// Slicing
-$first5 = $users->take(5);
-$after10 = $users->skip(10);
-$unique = $users->unique('email'); // first occurrence wins
-$reversed = $users->reverse();
-$merged = $users->merge($otherCollection);
-
-// Split into chunks — returns an array of Collections
-$chunks = $users->chunk(100);
-
-// Extracting data
-$names = $users->pluck('name'); // ['Ali', 'Sara', ...]
-$nameById = $users->pluck('name', 'id'); // [1 => 'Ali', 2 => 'Sara']
-$byId = $users->keyBy('id'); // plain array keyed by id
-$grouped = $users->groupBy('role'); // plain array grouped by role value
-
-// Aggregates — operate on a column across all items
-$total = $users->sum('score');
-$average = $users->avg('score');
-$lowest = $users->min('score');
-$highest = $users->max('score');
-
-// Serialization
-$arr = $users->toArray(); // array of arrays — uses Model::toArray() per item
-$json = $users->toJson();
-$json = json_encode($users); // identical
-(string) $users; // pretty-printed JSON
+```bash
+php webrium call UserController@index
+php webrium call UserController@find --params='[42]'
+php webrium call User@active --model
+php webrium call Report@generate --params='["2024-01", true]' --namespace="App\Services"
```
---
-## Schema Builder
-
-The Schema Builder lets you define your database structure in PHP rather than writing DDL statements by hand. It automatically generates the correct SQL for your database driver.
-
-### Creating a table
-
-```php
-use Foxdb\Schema;
-use Foxdb\Schema\Blueprint;
-
-Schema::create('users', function (Blueprint $table) {
- $table->id(); // BIGINT AUTO_INCREMENT PRIMARY KEY
-
- // String columns
- $table->string('name'); // VARCHAR(255)
- $table->string('email', 255)->unique();
- $table->char('code', 10);
- $table->text('bio')->nullable();
- $table->mediumText('content')->nullable();
- $table->longText('body')->nullable();
-
- // Numeric columns
- $table->integer('age')->default(0);
- $table->bigInteger('views')->default(0);
- $table->tinyInteger('status')->default(1);
- $table->float('score', 8, 2)->nullable();
- $table->decimal('price', 10, 2)->default(0);
-
- // Boolean
- $table->boolean('is_active')->default(true);
-
- // Special types
- $table->json('settings')->nullable(); // stored as TEXT/JSON
- $table->enum('role', ['admin', 'user', 'mod'])->default('user');
- $table->uuid('uuid');
- $table->binary('data')->nullable();
-
- // Dates and times
- $table->date('born_at')->nullable();
- $table->time('opens_at')->nullable();
- $table->dateTime('published_at')->nullable();
- $table->timestamp('last_login')->nullable();
-
- // Convenience shortcuts
- $table->timestamps(); // adds created_at + updated_at
- $table->softDeletes(); // adds deleted_at
-
- // Foreign keys
- $table->foreignId('category_id'); // BIGINT UNSIGNED NOT NULL
- $table->foreignIdFor(Category::class); // derives column from model
-
- // Indexes
- $table->index('role');
- $table->index(['first_name', 'last_name'], 'idx_full_name');
- $table->unique(['tenant_id', 'email']);
- $table->primary(['tenant_id', 'user_id']); // composite primary key
-
- // Foreign key constraints
- $table->foreign('category_id')
- ->references('id')
- ->on('categories')
- ->cascadeOnDelete();
-});
-```
-
-### Modifying an existing table
+## `db`
-```php
-Schema::table('users', function (Blueprint $table) {
- // Add a new column (nullable so existing rows are not affected)
- $table->integer('score')->nullable()->after('email')->change();
+Manages databases.
- // Rename a column
- $table->renameColumn('bio', 'about');
-
- // Remove columns
- $table->dropColumn('old_field');
- $table->dropColumn(['field_a', 'field_b']);
-
- // Remove indexes and constraints
- $table->dropIndex('idx_name');
- $table->dropUnique('idx_email');
- $table->dropForeign('fk_category_id');
-});
+```bash
+php webrium db [] [--use=] [--force]
```
-### Other schema operations
+| Action | Description |
+|---|---|
+| `list` | List all databases |
+| `tables` | List tables in a database |
+| `create` | Create a new database |
+| `drop` | Delete a database (prompts for confirmation) |
-```php
-Schema::drop('users'); // drop the table (fails if it doesn't exist)
-Schema::dropIfExists('users'); // safe version — no error if missing
-Schema::rename('old_table', 'new_table');
+| Option | Description |
+|---|---|
+| `--use, -u` | Specify a database for the `tables` action |
+| `--force, -f` | Skip confirmation prompt when dropping |
-Schema::hasTable('users'); // bool — check if table exists
-Schema::hasColumn('users', 'email'); // bool — check if column exists
-Schema::getColumnNames('users'); // array — all column names
+```bash
+php webrium db list
+php webrium db tables --use=my_database
+php webrium db create my_database
+php webrium db drop my_database
+php webrium db drop my_database --force
```
---
-## Migrations
-
-Migrations are PHP classes that describe a change to your database schema. Each migration has an `up()` method that applies the change and a `down()` method that reverses it. This lets you version your schema alongside your code and roll back changes when needed.
-
-### Writing a migration
-
-```php
-use Foxdb\Migrations\Migration;
-use Foxdb\Schema;
-use Foxdb\Schema\Blueprint;
-
-class CreateUsersTable extends Migration
-{
- public function up(): void
- {
- Schema::create('users', function (Blueprint $table) {
- $table->id();
- $table->string('name');
- $table->string('email')->unique();
- $table->string('password');
- $table->boolean('is_active')->default(true);
- $table->timestamps();
- });
- }
-
- public function down(): void
- {
- Schema::dropIfExists('users');
- }
-}
-```
-
-### Running migrations
-
-```php
-use Foxdb\Migrations\Migrator;
-
-// Point the Migrator at the folder containing your migration files
-$migrator = new Migrator('/path/to/migrations');
-
-// Run all migrations that have not been run yet
-$migrator->run();
+## `table`
-// Run at most 3 pending migrations
-$migrator->run(3);
+Inspects and manages individual tables, and can also execute SQL files against a database.
-// Roll back the last batch of migrations
-$migrator->rollback();
-
-// Roll back the last 2 batches
-$migrator->rollback(2);
-
-// Roll back everything — brings the database back to a blank state
-$migrator->reset();
-
-// Reset and then run everything — useful for refreshing a dev database
-$migrator->refresh();
-
-// Check what has and hasn't been run yet
-$migrator->status(); // array of migration status
+```bash
+php webrium table [--use=] [--force]
+```
+
+| Action | Description |
+|---|---|
+| `info` | Show table information |
+| `columns` | Show column details (name, type, nullable, key, default, extra) |
+| `drop` | Delete the table (prompts for confirmation) |
+| `truncate` | Remove all rows from the table (prompts for confirmation) |
+| `rename` | Rename an existing table |
+| `copy` | Copy table structure to a new table |
+| `exists` | Check whether a table exists |
+| `count` | Count rows in a table |
+| `run` | Execute a SQL file (`` is treated as a file path) |
+
+| Option | Description |
+|---|---|
+| `--use, -u` | Specify a database |
+| `--force, -f` | Skip confirmation prompts for destructive actions |
-// Check if anything is pending
-if ($migrator->hasPendingMigrations()) {
- echo "Database is not up to date.";
-}
+```bash
+php webrium table info users
+php webrium table columns orders --use=shop_db
+php webrium table drop sessions
+php webrium table drop sessions --force
+php webrium table rename old_table new_table
+php webrium table copy products products_backup
+php webrium table exists users
+php webrium table count orders
+php webrium table run sql/setup_tables.sql --use=shop_db
```
---
-## Query Log
-
-The query log lets you see every SQL statement that FoxDB executes, along with its bindings and execution time. This is useful for debugging, performance profiling, and detecting N+1 issues.
-
-```php
-// Enable logging before running your queries
-DB::enableQueryLog();
+## `log`
-$users = User::where('active', 1)->with('posts')->get();
-$count = DB::table('orders')->where('status', 'paid')->count();
+Manages Webrium log files stored in the logs directory.
-// Retrieve the log
-$log = DB::getQueryLog(); // array of QueryLogEntry objects
-
-foreach ($log as $entry) {
- echo $entry->sql; // SELECT * FROM `users` WHERE `active` = ?
- echo $entry->time; // execution time in milliseconds
- var_dump($entry->bindings);
-}
-
-// Shortcuts
-DB::getLastQuery(); // the most recent QueryLogEntry
-DB::getQueryCount(); // total number of queries run
-DB::getTotalQueryTime(); // sum of all execution times in ms
-DB::getSlowQueries(50.0); // all queries that took more than 50ms
-
-DB::disableQueryLog();
-DB::flushQueryLog(); // clear the log without disabling it
-```
-
-You can also attach hooks that fire before or after every query — useful for logging to an external system, adding metrics, or profiling:
-
-```php
-DB::beforeQuery(function (string $sql, array $bindings) {
- // Called just before each query is executed
- $this->logger->debug('Running query', ['sql' => $sql]);
-});
-
-DB::afterQuery(function (string $sql, array $bindings, float $timeMs) {
- // Called after each query completes
- if ($timeMs > 100) {
- $this->logger->warning('Slow query detected', ['sql' => $sql, 'time' => $timeMs]);
- }
-});
+```bash
+php webrium log []
```
----
+| Action | Description |
+|---|---|
+| `list` | List all log files |
+| `latest` | Display the most recent log file |
+| `file ` | Display a specific log file by name |
+| `clear` | Delete all log files |
-## Error Handling
-
-FoxDB throws typed exceptions so you can handle different failure scenarios specifically:
-
-```php
-use Foxdb\Exceptions\QueryException;
-use Foxdb\Exceptions\DatabaseException;
-use Foxdb\Exceptions\ModelNotFoundException;
-
-// findOrFail() throws ModelNotFoundException when no row matches
-try {
- $user = User::findOrFail(999);
-} catch (ModelNotFoundException $e) {
- // return a 404 response
-}
-
-// QueryException wraps any PDO error — gives you the SQL and bindings
-try {
- DB::table('users')->where('nonexistent_column', 1)->get();
-} catch (QueryException $e) {
- echo $e->getSql(); // the compiled SQL
- echo $e->getErrorCode(); // the database error code
- echo $e->getFormattedMessage(); // full formatted error string
- var_dump($e->getParams()); // the bindings array
-}
+```bash
+php webrium log list
+php webrium log latest
+php webrium log file 2024-01-15.log
+php webrium log clear
```
-If you prefer not to use exceptions — for example, in a legacy codebase — set `'throw_exceptions' => false` when registering the connection. Failed queries will return `false` instead of throwing.
-
---
-## Running Tests
-
-FoxDB includes a PHPUnit test suite split into two groups:
+## Plugin System
-- **Unit tests** require no database and test SQL generation and model logic.
-- **Integration tests** run real queries against a database. You choose which driver to use via an environment variable.
+Webrium Console includes a full plugin system for installing and managing distributable components.
```bash
-# Unit tests only — no database needed
-vendor/bin/phpunit --testsuite=unit
-
-# Integration tests with SQLite (no server required)
-DB_DRIVER=sqlite vendor/bin/phpunit --testsuite=integration
-
-# Integration tests with MySQL
-DB_DRIVER=mysql DB_DATABASE=foxdb_test DB_PASSWORD=secret \
- vendor/bin/phpunit --testsuite=integration
+php webrium plugin:install [--force] [--dry-run] [--no-backup]
+php webrium plugin:update [--force] [--no-backup]
+php webrium plugin:remove [--no-backup] [--keep-files]
+php webrium plugin:list
+php webrium plugin:info
+```
-# Integration tests with PostgreSQL
-DB_DRIVER=pgsql DB_PORT=5432 DB_DATABASE=foxdb_test DB_PASSWORD=secret \
- vendor/bin/phpunit --testsuite=integration
+The `source` argument accepts a local `.zip` file path or an `https://` URL:
-# Run everything
-DB_DRIVER=sqlite vendor/bin/phpunit --testsuite=all
+```bash
+php webrium plugin:install ./my-plugin.zip
+php webrium plugin:install https://example.com/releases/my-plugin.zip
+php webrium plugin:install https://github.com/user/repo/releases/download/v1.0.0/plugin.zip
```
-CI runs all three drivers automatically on every pull request via GitHub Actions.
+For full documentation on creating and distributing plugins, see the **[Plugin System Wiki](https://github.com/webrium/console/wiki/webrium-plugin-system)**.
---
## License
-Apache-2.0 — see [LICENSE](LICENSE).
\ No newline at end of file
+MIT
\ No newline at end of file
diff --git a/src/Connection/Connection.php b/src/Connection/Connection.php
index 51e19c6..ee3dfac 100644
--- a/src/Connection/Connection.php
+++ b/src/Connection/Connection.php
@@ -239,13 +239,31 @@ public function commit(): void
$this->transactionDepth--;
if ($this->transactionDepth === 0) {
+ // A DDL statement (CREATE TABLE, ALTER TABLE, DROP TABLE, ...) may
+ // have already triggered an implicit commit on platforms such as
+ // MySQL. In that case there is nothing left to commit — the data
+ // is already durable — so we simply acknowledge it instead of
+ // calling PDO::commit() again, which would throw "There is no
+ // active transaction" on PHP 8+.
+ if (! $this->pdo->inTransaction()) {
+ return;
+ }
+
try {
$this->pdo->commit();
} catch (PDOException $e) {
throw DatabaseException::transactionFailed('commit', $e);
}
} else {
- $this->pdo->exec("RELEASE SAVEPOINT trans{$this->transactionDepth}");
+ if (! $this->pdo->inTransaction()) {
+ return;
+ }
+
+ try {
+ $this->pdo->exec("RELEASE SAVEPOINT trans{$this->transactionDepth}");
+ } catch (PDOException $e) {
+ throw DatabaseException::transactionFailed('commit', $e);
+ }
}
}
@@ -256,6 +274,16 @@ public function rollBack(): void
{
if ($this->transactionDepth === 1) {
$this->transactionDepth = 0;
+
+ // If a DDL statement already committed the transaction implicitly,
+ // there is nothing left for PDO to roll back. Anything written
+ // before that statement is already permanent, so we surface this
+ // clearly instead of letting PDO throw a generic "no active
+ // transaction" error.
+ if (! $this->pdo->inTransaction()) {
+ throw DatabaseException::transactionImplicitlyCommitted();
+ }
+
try {
$this->pdo->rollBack();
} catch (PDOException $e) {
@@ -263,18 +291,32 @@ public function rollBack(): void
}
} elseif ($this->transactionDepth > 1) {
$this->transactionDepth--;
- $this->pdo->exec("ROLLBACK TO SAVEPOINT trans{$this->transactionDepth}");
+
+ if (! $this->pdo->inTransaction()) {
+ throw DatabaseException::transactionImplicitlyCommitted();
+ }
+
+ try {
+ $this->pdo->exec("ROLLBACK TO SAVEPOINT trans{$this->transactionDepth}");
+ } catch (PDOException $e) {
+ throw DatabaseException::transactionFailed('rollback', $e);
+ }
}
}
/**
* Check whether a transaction is currently active.
*
+ * Reflects the real PDO/driver state rather than only the internal
+ * nesting counter, since DDL statements (CREATE TABLE, ALTER TABLE, ...)
+ * can implicitly commit and close the transaction on platforms such as
+ * MySQL without going through this class.
+ *
* @return bool
*/
public function inTransaction(): bool
{
- return $this->transactionDepth > 0;
+ return $this->transactionDepth > 0 && $this->pdo->inTransaction();
}
// -----------------------------------------------------------------------
diff --git a/src/Exceptions/DatabaseException.php b/src/Exceptions/DatabaseException.php
index 80ef85c..ef75846 100644
--- a/src/Exceptions/DatabaseException.php
+++ b/src/Exceptions/DatabaseException.php
@@ -61,4 +61,23 @@ public static function transactionFailed(string $operation, ?Throwable $previous
$previous,
);
}
+
+ /**
+ * Create exception for a rollback that can no longer be honored because
+ * the underlying transaction was already implicitly committed (e.g. by a
+ * DDL statement such as CREATE TABLE / ALTER TABLE on MySQL).
+ *
+ * @param Throwable|null $previous
+ * @return static
+ */
+ public static function transactionImplicitlyCommitted(?Throwable $previous = null): static
+ {
+ return new static(
+ 'Cannot roll back: the transaction was already implicitly committed by a '
+ . 'DDL statement (e.g. CREATE TABLE / ALTER TABLE / DROP TABLE). Changes made '
+ . 'before that statement cannot be undone. Consider avoiding mixed DDL and DML '
+ . 'inside the same transaction.',
+ $previous,
+ );
+ }
}
diff --git a/tests/Integration/TransactionDdlTest.php b/tests/Integration/TransactionDdlTest.php
new file mode 100644
index 0000000..edbafc0
--- /dev/null
+++ b/tests/Integration/TransactionDdlTest.php
@@ -0,0 +1,168 @@
+transaction(function () use ($table) {
+ Schema::create($table, function (Blueprint $t) {
+ $t->id();
+ $t->string('name')->nullable();
+ });
+
+ return 'done';
+ });
+
+ $this->assertSame('done', $result);
+ $this->assertTrue(Schema::hasTable($table));
+ } finally {
+ Schema::dropIfExists($table);
+ }
+ }
+
+ /**
+ * inTransaction() must reflect the real driver state. After a DDL
+ * statement implicitly commits (MySQL) it should report false even
+ * though commit()/rollBack() have not been called yet.
+ */
+ public function testInTransactionReflectsRealDriverStateDuringDdl(): void
+ {
+ $table = 'transaction_ddl_test_state';
+ $conn = DB::connection();
+
+ try {
+ $conn->beginTransaction();
+
+ $this->assertTrue($conn->inTransaction());
+
+ Schema::create($table, function (Blueprint $t) {
+ $t->id();
+ });
+
+ $driver = strtolower((string) (getenv('DB_DRIVER') ?: 'sqlite'));
+ if ($driver === 'mysql') {
+ // MySQL already auto-committed; nothing left to be "in".
+ $this->assertFalse($conn->inTransaction());
+ } else {
+ // SQLite/PostgreSQL keep DDL transactional.
+ $this->assertTrue($conn->inTransaction());
+ }
+
+ $conn->commit();
+ } finally {
+ Schema::dropIfExists($table);
+ }
+ }
+
+ /**
+ * A rollback attempted after an implicit commit can no longer undo
+ * anything. Rather than letting PDO throw a generic "no active
+ * transaction" error, Connection::rollBack() should raise a clear,
+ * descriptive DatabaseException.
+ */
+ public function testRollbackAfterImplicitCommitThrowsDescriptiveException(): void
+ {
+ $driver = strtolower((string) (getenv('DB_DRIVER') ?: 'sqlite'));
+
+ if ($driver !== 'mysql') {
+ $this->markTestSkipped('Implicit commit on DDL is MySQL-specific behavior.');
+ }
+
+ $table = 'transaction_ddl_test_rollback';
+ $conn = DB::connection();
+
+ try {
+ $conn->beginTransaction();
+
+ Schema::create($table, function (Blueprint $t) {
+ $t->id();
+ });
+
+ $this->expectException(DatabaseException::class);
+ $this->expectExceptionMessageMatches('/implicitly committed/');
+
+ $conn->rollBack();
+ } finally {
+ Schema::dropIfExists($table);
+ }
+ }
+
+ /**
+ * Mixing a DML write with a later DDL statement inside one transaction:
+ * the DML write becomes permanent the moment the DDL statement runs
+ * (MySQL implicit commit), so a later rollBack() cannot erase it. This
+ * documents the real, expected behavior rather than masking it.
+ */
+ public function testDmlBeforeDdlIsNotRolledBackOnMysql(): void
+ {
+ $driver = strtolower((string) (getenv('DB_DRIVER') ?: 'sqlite'));
+
+ if ($driver !== 'mysql') {
+ $this->markTestSkipped('Implicit commit on DDL is MySQL-specific behavior.');
+ }
+
+ $table = 'transaction_ddl_test_dml';
+
+ try {
+ Schema::create($table, function (Blueprint $t) {
+ $t->id();
+ $t->string('name')->nullable();
+ });
+
+ $conn = DB::connection();
+ $conn->beginTransaction();
+
+ DB::table($table)->insert(['name' => 'should-survive']);
+
+ // This DDL statement implicitly commits everything above,
+ // including the insert.
+ Schema::table($table, function (Blueprint $t) {
+ $t->string('extra')->nullable();
+ });
+
+ try {
+ $conn->rollBack();
+ } catch (DatabaseException $e) {
+ // Expected: rollback can no longer be honored.
+ }
+
+ $this->assertSame(
+ 1,
+ DB::table($table)->where('name', 'should-survive')->count()
+ );
+ } finally {
+ Schema::dropIfExists($table);
+ }
+ }
+}