or81 / eloquent
A lightweight, dependency-free ORM and SQL query builder for PHP with a Laravel-style fluent API. Supports MySQL and SQLite, reads its credentials from .env, and queries by Jalali (Shamsi) date out of the box.
Requires
- php: >=8.0
- ext-pdo: *
Requires (Dev)
None
Suggests
- ext-pdo_mysql: To connect to MySQL.
- ext-pdo_sqlite: To connect to SQLite.
- vlucas/phpdotenv: Used to load the .env file when present; a built-in reader is used otherwise.
Provides
None
Conflicts
None
Replaces
None
README
A lightweight, dependency-free SQL query builder for PHP with a Laravel-style fluent API. Supports MySQL and SQLite, and queries by Jalali (Shamsi) date out of the box.
Every value goes through a bound parameter, so the builder is safe against SQL injection by construction. Only identifiers are interpolated, and those are quoted by the grammar.
Requirements
- PHP >= 8.0
- ext-pdo (
pdo_mysqland/orpdo_sqlite)
Installation
composer require or81/eloquent
Or clone the repository and register the Or81\Eloquent\ namespace against src/ in your own autoloader.
Configuration
From a .env file, with no setup
Put the credentials in a .env file next to your project and start querying. Nothing else is needed:
DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 DB_DATABASE=shop DB_USERNAME=root DB_PASSWORD=secret
use Or81\Eloquent\NDB; NDB::table('users')->get(); // already connected to shop
The file is found by looking in the working directory first, then walking up from the package, which reaches the project root when installed under vendor/. If vlucas/phpdotenv is installed it is used to load the file, exactly as an application would:
Dotenv::createImmutable($directory)->safeLoad();
Without that package a small built-in reader parses the same file, so nothing has to be installed. Real environment variables ($_ENV, $_SERVER, getenv) always win over the file, which is what you want on a server where the credentials are injected rather than committed.
Recognised keys:
| Key | Meaning |
|---|---|
DB_CONNECTION (or DB_DRIVER) |
mysql or sqlite |
DB_HOST, DB_PORT, DB_SOCKET |
where the server is |
DB_DATABASE |
database name, or the SQLite file path |
DB_USERNAME, DB_PASSWORD |
credentials |
DB_CHARSET, DB_COLLATION |
MySQL character set |
DB_DATE_STORAGE, DB_DATE_FORMAT |
the Jalali storage default for this connection |
SQLite needs only two:
DB_CONNECTION=sqlite DB_DATABASE=storage/database.sqlite
Point at a .env somewhere else — a directory or the file itself — before the first query:
NDB::useEnv(__DIR__ . '/config');
To see what the environment actually produced:
NDB::envConfig(); // the config array, or null when .env says nothing Or81\Eloquent\Env::loadedFrom(); // which file it came from
Overriding it by hand
NDB::configure() sits on top of whatever .env gave you, so you can change one key and leave the rest alone:
NDB::configure(['database' => 'shop_test']); // same host and credentials, different database
Or ignore the environment entirely and state everything:
NDB::configure([ 'driver' => 'mysql', 'host' => '127.0.0.1', 'port' => 3306, 'database' => 'shop', 'username' => 'root', 'password' => 'secret', 'charset' => 'utf8mb4', ]); // SQLite (the file is created if it does not exist; ':memory:' also works) NDB::configure([ 'driver' => 'sqlite', 'database' => __DIR__ . '/storage/database.sqlite', ]);
The connection is opened lazily, on the first query.
Multiple connections
A second connection is configured by name. In .env, prefix its keys with that name:
REPORTING_DB_CONNECTION=mysql REPORTING_DB_DATABASE=analytics
NDB::configure([...], 'reporting'); // or leave it to the prefixed .env keys NDB::connection('reporting')->select('select count(*) from events'); NDB::table('events')->getConnection();
Only the default connection falls back to the unprefixed DB_* keys, so a typo in a connection name fails loudly instead of quietly pointing at the main database.
Long-running scripts
MySQL closes a connection that has been idle longer than wait_timeout (eight
hours by default, but often minutes on shared hosting). The client only finds
out on the next statement, which fails with SQLSTATE[HY000]: General error: 2006 MySQL server has gone away. An importer that spends a while on an API
call, a file, or a sleep between rounds runs into this.
Nothing has to be done about it: when a statement dies with the connection, it is run once more on a fresh one.
foreach ($students as $student) { sleep(60); // the connection times out here NDB::table('vadana')->upsert($student, ['national_code']); // reconnects and goes on }
The retry is deliberately narrow, because repeating a statement is only safe when it never reached the server:
- Only a dropped connection is retried - 2006, 2013, a killed or shut-down connection, a broken pipe. A duplicate key or a bad column is thrown at once.
- Only once. A second drop in a row is thrown, rather than looping against a server that is down.
- Never inside a transaction. Everything the transaction had done is already
gone, so replaying one statement would write it on top of nothing. The
QueryExceptionis thrown instead, with the transaction level reset so therollBack()in your catch block does not fail on top of it. - Never on a PDO you handed over with
setPdo().
To turn it off and see every drop yourself:
NDB::configure(['reconnect' => false]); // or DB_RECONNECT=false in .env
Either way, isLostConnection() tells a dropped connection from a bad query:
try { NDB::table('vadana')->upsert($rows, ['national_code']); } catch (QueryException $e) { if ($e->isLostConnection()) { // the retry did not get through either: the server is down or restarting } }
Before a batch that follows a long quiet stretch you can check the connection first, so the work does not start on a dead one:
while ($job = $queue->next()) { // may block for minutes NDB::ping(); // reconnects if the server dropped us NDB::table('jobs')->insert($job); }
ping() returns false when the server cannot be reached at all, and when a
transaction is open - reconnecting there would throw the transaction away
silently.
A "gone away" that arrives immediately, on a connection that was busy the
whole time, is usually a different problem: a single statement larger than the
server's max_allowed_packet. Insert in smaller chunks, or raise the setting.
An existing PDO instance
$connection = new \Or81\Eloquent\Connection(['driver' => 'mysql']); $connection->setPdo($yourPdo); NDB::setConnection($connection);
Managing connections
NDB::connection(); // the Connection object NDB::getPdo(); // the underlying PDO NDB::setDefaultConnection('reporting'); NDB::getDefaultConnection(); NDB::purge('reporting'); // drop the connection, keep its config NDB::forget('reporting'); // drop both, so .env is read again NDB::reconnect(); // close it and open a new one now NDB::ping(); // is the server still there? reconnects if not
Getting a builder
There are two ways in, and they return the same query builder. Pick whichever suits the job.
A table, directly. Rows come back as Row objects, so columns are read as properties.
$user = NDB::table('users')->where('active', 1)->first(); $user->name; $user->id;
A model. Everything about the table is declared on a class, and rows come back as objects with casts applied. This is the Model half of the package.
class User extends Model { protected array $fillable = ['name', 'email']; protected array $casts = ['is_active' => 'bool']; } User::where('is_active', 1)->get(); // [User, User, ...]
Both accept exactly the same clauses, so nothing in the rest of this file changes between them.
Models
A Model describes one table: its name, its key, which columns may be filled, how each column is typed, and how its dates are stored. Extend it and you get the query builder, attribute casting, timestamps, soft deletes and scopes.
use Or81\Eloquent\Model; use Or81\Eloquent\ModelBuilder; use Or81\Eloquent\Jalali; class Order extends Model { protected array $fillable = ['customer_id', 'code', 'status', 'total', 'meta', 'issued_at']; protected array $casts = [ 'total' => 'int', 'meta' => 'array', 'is_paid' => 'bool', 'issued_at' => 'jalali', ]; protected array $dateStorage = [ 'issued_at' => [Jalali::JALALI, 'Y/m/d'], ]; protected bool $softDeletes = true; public function customer(): ModelBuilder { return $this->belongsTo(Customer::class); } public function scopePaid(ModelBuilder $query): void { $query->where('status', 'paid'); } }
$order = Order::create([ 'customer_id' => 1, 'code' => 'ORD-1001', 'status' => 'paid', 'total' => '250000', 'meta' => ['gift' => true], 'issued_at' => '1403/05/26', ]); $order->total; // 250000, an int $order->meta['gift']; // true, decoded from JSON $order->issued_at->format('l j F'); // جمعه 26 مرداد, a Jalali object Order::paid()->whereJalaliThisMonth('issued_at')->get();
What you declare
| Property | Default | What it does |
|---|---|---|
$table |
from the class name | BlogPost → blog_posts, Person → people |
$connection |
null |
the named connection this model lives on |
$primaryKey |
'id' |
|
$keyType |
'int' |
'string' for a UUID |
$incrementing |
true |
false when you supply the key yourself |
$fillable |
[] |
the columns create() and fill() accept |
$guarded |
['*'] |
consulted only when $fillable is empty |
$casts |
[] |
column → type, see below |
$dateStorage |
[] |
how date columns are stored, see Jalali storage |
$hidden |
[] |
columns left out of toArray() / toJson() |
$visible |
[] |
when set, the only columns kept |
$appends |
[] |
accessor-backed values added to the output |
$timestamps |
true |
maintain created_at and updated_at |
$softDeletes |
false |
turn delete() into a deleted_at stamp |
Nothing is mass assignable until you list it in $fillable. If every attribute of a create() call is rejected the model says so rather than quietly writing a blank row:
LogicException: No attribute of [Order] may be mass assigned. Add the columns to
its $fillable list, or use forceFill() to bypass the check.
Casts
protected array $casts = [ 'total' => 'int', // also: integer 'rate' => 'float', // also: double, real 'price' => 'decimal:2', // a string with two places 'code' => 'string', 'is_paid' => 'bool', // also: boolean 'meta' => 'array', // JSON in the column, an array in PHP 'payload' => 'json', // the same thing 'settings' => 'object', 'seen_at' => 'timestamp', // an int 'birthday' => 'date', // DateTimeImmutable at midnight 'created_at' => 'datetime', // DateTimeImmutable 'issued_at' => 'jalali', // Jalali ];
A cast runs both ways. array is encoded to JSON when written and decoded when read; jalali accepts a string, an array, a Jalali or a DateTimeInterface and writes whatever $dateStorage says the column holds. An unknown cast name throws rather than being ignored.
Accessors and mutators
class Order extends Model { protected array $appends = ['summary']; public function getSummaryAttribute(): string // $order->summary { return $this->code . ' (' . $this->status . ')'; } public function setCodeAttribute($value): void // $order->code = 'ord-1' { $this->attributes['code'] = strtoupper((string) $value); } }
An accessor for a real column receives the cast value and may reshape it. One with no column behind it is computed on the fly; list it in $appends to have it show up in toArray().
Scopes
A scopeSomething() method on the model becomes Something() on the query:
public function scopeActive(ModelBuilder $query): void { $query->where('is_active', 1); } public function scopeInCity(ModelBuilder $query, string $city): void { $query->where('city', $city); }
Customer::active()->inCity('تهران')->orderBy('name')->get();
Calling a method that is neither a builder method nor a scope names the scope you would have to write.
Reading and writing
Order::all(); Order::find(1); Order::findOrFail(1); // ModelNotFoundException when missing Order::findOr(1, fn () => $default); Order::where('status', 'paid')->first(); Order::where('status', 'paid')->firstOrFail(); Order::where('status', 'paid')->firstOr(fn () => $default); Order::query()->firstWhere('status', 'paid'); Order::where('code', 'ORD-1')->sole(); // exactly one match, or it is an error Order::count(); Order::query()->paginate(20, $page); // 'data' holds models Order::query()->cursor(); // streams models Order::query()->chunkById(200, fn (array $orders) => ...); Order::query()->toBase(); // plain arrays instead
Order::create([...]); Order::query()->createMany([[...], [...]]); // one transaction Order::query()->forceCreate([...]); // ignores $fillable Order::query()->firstOrNew(['code' => 'X']); // not saved Order::query()->firstOrCreate(['code' => 'X'], ['status' => 'pending']); Order::query()->updateOrCreate(['code' => 'X'], ['status' => 'paid']); Order::query()->createOrUpdate(['code' => 'X'], ['status' => 'paid']); Order::destroy(1); // delete by key, without loading first Order::destroy(1, 2, 3); Order::destroy([1, 2, 3]);
$order = Order::find(1); $order->status = 'shipped'; $order->save(); // writes only what changed $order->update(['status' => 'shipped']); $order->delete(); $order->refresh(); // re-read into this instance $fresh = $order->fresh(); // re-read into a new one $order->isDirty(); // anything changed since it was loaded? $order->isDirty('status'); $order->getDirty(); // ['status' => 'shipped'] $order->getOriginal('status'); // what it was when loaded $order->getAttributes(); // the raw column values $order->wasChanged(); // did the last save() write anything? $order->wasChanged('status'); $order->getChanges(); // what it wrote $order->touch(); // bump updated_at and nothing else $order->only(['code', 'total']); // a subset, cast as usual $order->except(['meta']); $order->is($other); // same row? $order->isNot($other); $copy = $order->replicate(); // an unsaved copy, without the key or timestamps
save() writes only the changed columns, and does nothing at all when nothing changed. $timestamps keeps created_at and updated_at current, and respects $dateStorage — a project that stores Jalali text gets Jalali timestamps.
Soft deletes
protected bool $softDeletes = true;
$order->delete(); // sets deleted_at $order->trashed(); // true Order::find(1); // null: soft-deleted rows are filtered out Order::withTrashed()->find(1); Order::onlyTrashed()->get(); $order->restore(); $order->forceDelete(); // really gone Order::onlyTrashed()->where('status', 'cancelled')->restore(); Order::withTrashed()->where('total', 0)->forceDelete();
Relations
Each relation returns a query, so finish it yourself. Eager loading is not included: this is a query builder with a model layer on top, not a full ORM.
class Customer extends Model { public function orders(): ModelBuilder { return $this->hasMany(Order::class); // orders.customer_id } } class Order extends Model { public function customer(): ModelBuilder { return $this->belongsTo(Customer::class); // orders.customer_id } }
$customer->orders()->get(); $customer->orders()->where('status', 'paid')->sum('total'); $order->customer()->first(); $customer->hasOne(Order::class)->first();
Keys are guessed from the class names and can be given explicitly: hasMany(Order::class, 'buyer_id', 'id').
Output
$order->toArray(); // casts applied, $hidden removed, $appends added $order->toJson(); // the same, JSON encoded with readable Unicode json_encode($order); // works: the model is JsonSerializable $order['code']; // works: the model is ArrayAccess
Jalali and DateTimeInterface values become strings on the way out, so the result is always JSON-safe.
Or skip the model
If you want the table-name inference without any of the above, extend NDB instead. You get a plain query builder returning Row objects:
class BlogPost extends NDB {} // -> blog_posts class Person extends NDB {} // -> people class Invoice extends NDB { protected static string $table = 'legacy_invoices'; } BlogPost::query()->where('published', 1)->get();
Selecting
NDB::table('users')->get(); NDB::table('users')->select('id', 'name')->get(); NDB::table('users')->distinct()->select('role')->get(); NDB::table('users', 'u')->select('u.name as author')->get(); NDB::table('users')->selectRaw('count(*) as total, max(votes) as top')->first(); // Correlated sub-select NDB::table('users')->select('name')->selectSub( NDB::table('orders')->selectRaw('count(*)')->whereColumn('orders.user_id', 'users.id'), 'orders_count' )->get(); // Sub-query as the FROM clause NDB::table('x')->fromSub(NDB::table('users')->where('active', 1), 'active_users')->get();
Results are objects
get() returns a list of Row objects and first(), find(), findOrFail() and sole() return one, so columns are read as properties:
$user = NDB::table('users')->find(3); $user->name; $user->email; foreach (NDB::table('users')->get() as $user) { echo $user->name; }
Array access still works, so nothing written against the older array results has to change:
$user['name']; foreach ($user as $column => $value) { ... } count($user); json_encode($user);
A Row also has:
$user->toArray(); // the plain associative array $user->toJson(); $user->get('nickname', 'none'); // with a default $user->has('nickname'); // present, even if null $user->only(['id', 'name']); $user->except(['password']); $user->keys(); $user->values();
A column that is not in the result reads as null; use has() to tell an absent column from one that is really null.
When you want plain arrays after all — for array_column, a CSV writer, or an assertion — ask for them:
NDB::table('users')->toBase(); // [['id' => 1, 'name' => 'Alice'], ...]
pluck(), value() and the aggregates were never rows, and are unchanged.
Single results
NDB::table('users')->first(); // ?Row NDB::table('users')->firstOrFail(); // throws RecordNotFoundException NDB::table('users')->firstOr(fn () => $default); NDB::table('users')->firstWhere('role', 'admin'); NDB::table('users')->sole(); // exactly one match, or it is an error NDB::table('users')->find(3); // by primary key NDB::table('users')->findOrFail(3); NDB::table('users')->findOr(3, fn () => $default); NDB::table('users')->where('id', 3)->value('email'); NDB::table('users')->pluck('name'); // ['Alice', 'Bob'] NDB::table('users')->pluck('name', 'id'); // [1 => 'Alice', 2 => 'Bob'] NDB::table('users')->implode('name', ', '); // 'Alice, Bob' NDB::table('users')->where('role', 'admin')->exists(); NDB::table('users')->where('role', 'ghost')->doesntExist();
find(), findOrFail(), create() and delete($id) look rows up by id. Point them at a different key once:
NDB::table('settings')->keyName('setting_key')->find('theme');
Where clauses
->where('votes', '>', 100) ->where('name', 'John') // '=' is implied ->where(['name' => 'John', 'active' => 1]) // AND of every pair ->where([['votes', '>', 100], ['name', 'John']]) ->orWhere('votes', '>', 50) ->whereNot('role', 'guest') ->orWhereNot('role', 'guest') ->whereIn('id', [1, 2, 3]) ->whereNotIn('role', ['guest', 'banned']) ->whereNull('deleted_at') ->whereNotNull('email_verified_at') ->whereBetween('age', [18, 30]) ->whereNotBetween('age', [18, 30]) ->whereColumn('updated_at', '>', 'created_at') ->whereLike('name', '%jo%') ->whereNotLike('name', '%spam%') ->whereRaw('length(name) > ?', [5]) ->whereDate('created_at', '2024-03-05') ->whereYear('created_at', 2024) ->whereMonth('created_at', 3) ->whereDay('created_at', 5) ->whereTime('created_at', '>', '09:00:00')
Every one of these has an orWhere* counterpart.
An operator the builder does not recognise throws rather than compiling into broken SQL, so where('age', '=>', 5) is caught at the call site. Reach for whereRaw() when you need something exotic.
One condition, several columns
The search-box case. Each of these is compiled as a single group, so it cannot leak into the clauses around it:
->whereAny(['name', 'email', 'phone'], 'like', "%{$term}%") // any column matches ->whereAll(['name', 'email'], 'like', "%{$term}%") // every column matches ->whereNone(['name', 'email'], 'like', "%{$term}%") // no column matches
where "role" = ? and ("name" like ? or "email" like ? or "phone" like ?)
orWhereAny() and orWhereAll() join the group on with or.
Grouped conditions
NDB::table('users') ->where('role', 'user') ->where(function (Builder $query) { $query->where('votes', '>', 100)->orWhere('name', 'Bob'); }) ->get(); // where "role" = ? and ("votes" > ? or "name" = ?)
Sub-query conditions
->whereIn('id', function (Builder $query) { $query->from('orders')->select('user_id')->where('total', '>', 100); }) ->whereExists(function (Builder $query) { $query->from('orders')->whereColumn('orders.user_id', 'users.id'); }) ->where('votes', '>', function (Builder $query) { $query->from('users')->selectRaw('avg(votes)'); })
Jalali (Shamsi) dates
Query with Persian dates against columns that hold whatever your schema already holds. Nothing needs to change in the database, and no Jalali support is needed from MySQL or SQLite.
NDB::table('orders')->whereJalaliDate('created_at', '1403/05/26')->get(); NDB::table('orders')->whereJalaliBetween('created_at', ['1403/01/01', '1403/06/31'])->get(); NDB::table('orders')->whereJalaliThisMonth('created_at')->count();
Every clause compiles to a half-open range over the plain column, so an index on it is still used:
where ("created_at" >= '2024-08-16' and "created_at" < '2024-08-17')
The column is never wrapped in a function, which is what makes this different from whereDate() / whereYear().
Telling the builder how a column is stored
Two things vary independently: the calendar (Gregorian or Jalali) and the column type (date, datetime, varchar, timestamp). What the builder actually needs to know is which value to compare against, so there are three storage modes:
| Mode | Fits | Compared against |
|---|---|---|
Jalali::GREGORIAN |
date, datetime, MySQL timestamp, or a varchar holding 2024-08-16 |
'2024-08-16' |
Jalali::JALALI |
a varchar (or date) holding 1403/05/26 |
'1403/05/26' |
Jalali::UNIX |
an integer column holding seconds since the epoch | 1723800000 |
Jalali::GREGORIAN is the default. Declare anything else:
NDB::table('invoices') ->dateStorage('issued_at', Jalali::JALALI) // 1403/05/26 ->dateStorage('paid_at', Jalali::JALALI, 'Y-m-d') // 1403-05-26 ->dateStorage('logged_at', Jalali::UNIX) ->whereJalaliMonth('issued_at', 1403, 5) ->get();
Per model, so you only say it once:
class Invoice extends NDB { protected static array $dateStorage = [ 'issued_at' => [Jalali::JALALI, 'Y-m-d'], 'logged_at' => Jalali::UNIX, ]; } Invoice::query()->whereJalaliThisYear('issued_at')->get();
Per connection, when a whole legacy database stores Jalali text:
NDB::configure([..., 'date_storage' => Jalali::JALALI, 'date_format' => 'Y/m/d']);
Or let the builder work it out by reading one stored value. A Jalali year lands in 1000–1699 and a Gregorian one does not, which is what separates the two. The result is cached for the rest of the process, so the probe runs once per column:
NDB::table('invoices')->detectDateStorage('issued_at')->whereJalaliToday('issued_at')->get();
Range comparison relies on the stored text sorting chronologically, so a varchar must hold a zero-padded year-month-day value. A format like Y/n/j (1403/5/6) or d/m/Y is refused with an explanation rather than silently returning wrong rows.
The clauses
->whereJalali('created_at', '1403/05/26') // that whole day ->whereJalali('created_at', '>=', '1403/05/26') // also > < <= != <> ->whereJalaliDate('created_at', '1403/05/26') ->whereJalaliBetween('created_at', ['1403/01/01', '1403/06/31']) // both ends inclusive ->whereJalaliNotBetween('created_at', ['1403/01/01', '1403/06/31']) ->whereJalaliYear('created_at', 1403) ->whereJalaliMonth('created_at', 1403, 5) // the year is required: a month alone is not a range ->whereJalaliWeek('created_at', '1403/05/24') // the Saturday-to-Friday week containing that day ->whereJalaliToday('created_at') ->whereJalaliYesterday('created_at') ->whereJalaliTomorrow('created_at') ->whereJalaliThisWeek('created_at') ->whereJalaliThisMonth('created_at') ->whereJalaliLastMonth('created_at') ->whereJalaliThisYear('created_at') ->whereJalaliLastDays('created_at', 7) // today and the six days before it
whereJalali, whereJalaliDate, whereJalaliBetween, whereJalaliYear and whereJalaliMonth each have an orWhereJalali* counterpart. Values may be a string ('1403/05/26', '1403-5-26', Persian digits), an array ([1403, 5, 26]), a Jalali, or a DateTimeInterface.
Ordering needs nothing special: all three storage modes already sort chronologically, so orderBy('created_at') is correct as it stands.
Jalali output
NDB::table('orders') ->castJalali('created_at', 'l j F Y ساعت H:i') ->whereJalaliToday('created_at') ->get(); // created_at => 'جمعه 26 مرداد 1403 ساعت 14:30'
The Jalali class
A self-contained immutable date, modelled on verta's API but with no dependency on it. Every mutator returns a new instance.
use Or81\Eloquent\Jalali; Jalali::now(); Jalali::today(); Jalali::create(1403, 5, 26); Jalali::parse('1403/05/26 14:30'); // slashes, dashes, Persian or Arabic digits Jalali::fromGregorian('2024-08-16'); // string, DateTimeInterface or timestamp Jalali::fromTimestamp(1723800000); $date = Jalali::parse('1403/05/26'); $date->year(); $date->month(); $date->day(); $date->dayOfWeek(); // 0 = شنبه $date->dayOfYear(); // 150 $date->daysInMonth(); // 31 $date->isLeapYear(); // true $date->monthName(); // مرداد $date->dayName(); // جمعه $date->toDateString(); // 1403/05/26 $date->toGregorianDateString(); // 2024-08-16 $date->toGregorian(); // DateTimeImmutable $date->toTimestamp(); $date->format('l j F Y'); // جمعه 26 مرداد 1403 $date->addDays(10); $date->subDays(10); $date->addMonths(2); $date->addYears(1); // the day is clamped to a shorter month $date->startOfWeek(); $date->endOfWeek(); // Saturday .. Friday $date->startOfMonth(); $date->endOfMonth(); $date->startOfYear(); $date->endOfYear(); $date->lessThan($other); $date->greaterThan($other); $date->equalTo($other); Jalali::toPersianDigits('1403/05/26'); // ۱۴۰۳/۰۵/۲۶ Jalali::toEnglishDigits('۱۴۰۳/۰۵/۲۶'); // 1403/05/26
format() accepts Y y m n d j H G i s F M l D N w t L z a A U, and a backslash escapes the next character. z is the 1-based day of the year.
A verta instance can be handed to any of these directly — Jalali::parse($verta) picks up its datetime().
Jalali::now(), today() and the UNIX storage mode all read PHP's default timezone, so set it once at bootstrap:
date_default_timezone_set('Asia/Tehran');
Joins
NDB::table('users') ->join('posts', 'users.id', '=', 'posts.user_id') ->select('users.name', 'posts.title') ->get(); NDB::table('users')->join('posts', 'users.id', 'posts.user_id'); // '=' is implied NDB::table('users')->leftJoin('posts', 'users.id', '=', 'posts.user_id'); NDB::table('users')->rightJoin('posts', 'users.id', '=', 'posts.user_id'); NDB::table('users')->crossJoin('colors');
Pass a closure for compound conditions. The closure receives a JoinClause, which is a full builder plus on() / orOn():
NDB::table('users')->join('posts', function (JoinClause $join) { $join->on('users.id', '=', 'posts.user_id') ->where('posts.published', 1); })->get();
Sub-query joins:
NDB::table('users')->joinSub( NDB::table('posts')->select('user_id')->selectRaw('count(*) as total')->groupBy('user_id'), 'stats', 'users.id', '=', 'stats.user_id' )->get();
Grouping, ordering and limits
->groupBy('role') ->groupByRaw('year(created_at)') ->having('total', '>', 2) ->havingBetween('total', [2, 10]) ->havingRaw('count(*) > ?', [2]) ->orderBy('name') ->orderByDesc('votes') ->orderByRaw('length(name) desc') ->latest() // order by created_at desc ->oldest() ->inRandomOrder() ->reorder() // drop every order ->limit(10)->offset(20) // take() / skip() are aliases ->forPage(3, 20)
Aggregates
NDB::table('users')->count(); NDB::table('users')->count('email'); // count of non-null emails NDB::table('users')->distinct()->count('role'); NDB::table('users')->max('votes'); NDB::table('users')->min('votes'); NDB::table('users')->sum('votes'); NDB::table('users')->avg('votes');
Pagination and chunking
$page = NDB::table('users')->orderBy('id')->paginate(perPage: 20, page: 3); // ['data' => [...], 'total' => 57, 'per_page' => 20, 'current_page' => 3, // 'last_page' => 3, 'from' => 41, 'to' => 57] NDB::table('users')->orderBy('id')->chunk(200, function (array $rows, int $page) { // return false to stop early }); NDB::table('users')->orderBy('id')->each(function (Row $row) { // one row at a time, fetched 1000 at a time }); // Stream without buffering the whole result set foreach (NDB::table('logs')->cursor() as $row) { echo $row->message; }
Reading a table too big for memory
get() builds the whole result set before returning it: a row array and a Row object for every record. On a few hundred thousand rows that is enough to exhaust a default 128 MB limit.
// 268,000 rows of one column NDB::table('vadana')->select('student_code')->get(); // 140 MB NDB::table('vadana')->pluck('student_code'); // 20 MB
pluck() streams and keeps only the values, so use it whenever you want one column. cursor() streams whole rows, holding one at a time:
foreach (NDB::table('vadana')->cursor() as $student) { // one row in memory, however many the table has }
PDO buffers by default on MySQL, which would pull the entire result set into memory at execute() and make fetching one row at a time pointless. cursor() turns buffering off for its own duration and puts it back afterwards, including when you break or an exception is thrown. While an unbuffered cursor is open MySQL will not run another query on that connection — finish or leave the loop before querying again, or use a second connection.
Narrowing the query is better still. Loading only the keys you are about to look at costs a fraction of loading them all:
$seen = array_flip( NDB::table('vadana') ->whereNotNull('student_code') ->whereBetween('student_code', [$from, $to]) ->pluck('student_code') ); // 4 MB
chunk() pages with limit/offset, so rows the callback deletes or reorders shift the pages still to come. When the callback writes to the same table, page by key instead:
NDB::table('users')->chunkById(200, function (array $rows) { foreach ($rows as $row) { NDB::table('users')->where('id', $row->id)->update(['migrated' => 1]); } });
chunkById walks in key order and remembers where it got to, so nothing is skipped or seen twice. Pass a column name as the third argument when the key is not id.
Conditional clauses
NDB::table('users') ->when($request['role'] ?? null, fn (Builder $q, $role) => $q->where('role', $role)) ->unless($includeTrashed, fn (Builder $q) => $q->whereNull('deleted_at')) ->get();
Inserting
Getting the row back
create() inserts one row and reads it back, so the generated key and any column defaults the database filled in are there straight away:
$user = NDB::table('users')->create(['name' => 'John', 'email' => 'john@example.com']); $user->id; // 4, from the database $user->role; // 'user', the column default
// Several at once, in one transaction: if any row fails, none of them land $users = NDB::table('users')->createMany([ ['name' => 'Alice', 'email' => 'alice@example.com'], ['name' => 'Bob', 'email' => 'bob@example.com'], ]); // Find it or make it NDB::table('users')->firstOrCreate( ['email' => 'john@example.com'], // what to match on ['name' => 'John', 'votes' => 0] // what to add when creating ); // Update it or make it. Either way the stored row comes back. NDB::table('users')->updateOrCreate( ['email' => 'john@example.com'], ['votes' => 42] ); NDB::table('users')->createOrUpdate([...], [...]); // the same method, other name
Plain inserts
NDB::table('users')->insert(['name' => 'John', 'email' => 'john@example.com']); NDB::table('users')->insert([ ['name' => 'Alice', 'email' => 'alice@example.com'], ['name' => 'Bob', 'email' => 'bob@example.com'], ]); $id = NDB::table('users')->insertGetId(['name' => 'Carol']); // Skip rows that collide with a unique index NDB::table('users')->insertOrIgnore([['email' => 'alice@example.com']]); // Insert, or update the listed columns on collision NDB::table('users')->upsert( [['email' => 'alice@example.com', 'votes' => 42]], ['email'], // unique columns (required by SQLite, ignored by MySQL) ['votes'] // columns to update; defaults to every inserted column ); // Copy rows in from another query, without pulling them through PHP NDB::table('archived_orders')->insertUsing( ['code', 'total'], NDB::table('orders')->select('code', 'total')->where('status', 'done') );
Every row of a multi-row insert() must carry the same columns. One that does not is reported by position, rather than becoming a VALUES list the driver rejects with a vaguer message.
Updating and deleting
Both return the number of affected rows.
NDB::table('users')->where('id', 1)->update(['name' => 'John Smith']); NDB::table('users')->where('id', 1)->update(['votes' => NDB::raw('votes + 1')]); NDB::table('users')->where('id', 1)->increment('votes'); NDB::table('users')->where('id', 1)->increment('votes', 5, ['updated_at' => date('c')]); NDB::table('users')->where('id', 1)->decrement('votes'); NDB::table('users')->updateOrInsert( ['email' => 'john@example.com'], ['name' => 'John', 'votes' => 0] ); NDB::table('users')->where('votes', '<', 1)->delete(); NDB::table('users')->delete(1); // by primary key NDB::table('users')->truncate();
orderBy() / limit() on an update or delete are MySQL-only. On SQLite the builder throws rather than silently dropping them.
Transactions
NDB::transaction(function () { NDB::table('users')->where('id', 1)->decrement('balance', 100); NDB::table('users')->where('id', 2)->increment('balance', 100); }); // Retry up to 3 times on failure NDB::transaction($callback, attempts: 3); // Manual control NDB::beginTransaction(); NDB::commit(); NDB::rollBack();
Nested transaction() calls use savepoints, so an inner rollback does not discard the outer work.
Raw statements
NDB::select('select * from users where votes > ?', [100]); NDB::selectOne('select * from users where id = ?', [1]); NDB::insert('insert into users (name) values (?)', ['John']); NDB::update('update users set votes = ? where id = ?', [10, 1]); NDB::delete('delete from users where id = ?', [1]); NDB::statement('alter table users add column age integer'); NDB::unprepared('create table t (id integer)'); // DDL, no bindings
Use NDB::raw() for a fragment that must not be quoted. Never build one from user input:
NDB::table('users')->select(NDB::raw('count(*) as total'))->first();
Values still belong in bindings. Put a ? where each one goes and pass them in the same order, and the fragment stays safe wherever it is used — in a select, a where, a having, an insert or an update:
NDB::table('users')->update([ 'nickname' => NDB::raw('coalesce(nullif(trim(nickname), \'\'), ?, nickname)', [$nickname]), ]); // update "users" set "nickname" = coalesce(nullif(trim(nickname), ''), ?, nickname) // bindings: [$nickname]
This is the fill-in-the-blanks update: write the incoming value only where the column is currently empty, and leave it alone otherwise. See the worked example.
Debugging
$query = NDB::table('users')->where('votes', '>', 100); $query->toSql(); // select * from "users" where "votes" > ? $query->getBindings(); // [100] $query->toRawSql(); // bindings interpolated — for reading only $query->dump(); // print and continue $query->dd(); // print and exit NDB::enableQueryLog(); NDB::table('users')->get(); NDB::getQueryLog(); // [['query' => ..., 'bindings' => [...], 'time' => 0.42]]
Error handling
A failed statement throws Or81\Eloquent\QueryException, carrying the SQL and bindings that caused it:
use Or81\Eloquent\QueryException; try { NDB::table('users')->insert(['nope' => 1]); } catch (QueryException $e) { $e->getSql(); $e->getBindings(); $e->getPrevious(); // the underlying PDOException }
QueryExceptionis not aPDOException. It extendsRuntimeExceptionand keeps the driver's exception as its previous.catch (PDOException $e)will not fire, and$e->errorInfois not on it — reach for the helpers below instead.
Telling one failure from another
try { NDB::table('students')->insert($student); } catch (QueryException $e) { if ($e->isUniqueViolation()) { // this row is already there } elseif ($e->isForeignKeyViolation()) { // it points at something that does not exist } else { throw $e; } }
isUniqueViolation() covers MySQL's 1062 and SQLite's 19 / 1555 / 2067, so nothing has to test for a driver code by hand. The raw values are there when you want them:
$e->getSqlState(); // '23000' $e->getDriverCode(); // 1062 on MySQL, 19 on SQLite $e->getErrorInfo(); // ['23000', 1062, 'Duplicate entry ...']
isLostConnection() is the third of them: it says the server was gone rather
than the statement wrong. See long-running scripts for
what the connection does about that on its own.
Better still, do not collide
Catching a duplicate to then update is a race: another process can insert the same row between your insert and your update. upsert() settles it in one atomic statement, and the worked example below shows the fill-in-the-blanks version.
Worked examples
Every snippet below is executed by tests/examples_test.php, so they are all known to run.
A filtered, paginated listing
The classic search page: several optional filters, a joined table, a Jalali date range, and a page of results. when() keeps the optional filters from turning into a pile of if statements.
function searchOrders(array $filters, int $page = 1): array { return NDB::table('orders') ->join('customers', 'orders.customer_id', '=', 'customers.id') ->select('orders.code', 'orders.total', 'orders.status', 'customers.name') ->when($filters['q'] ?? null, fn (Builder $q, $term) => $q->where(function (Builder $inner) use ($term) { $inner->whereLike('customers.name', "%{$term}%") ->orWhereLike('orders.code', "%{$term}%"); })) ->when($filters['status'] ?? null, fn (Builder $q, $status) => $q->whereIn('orders.status', (array) $status)) ->when($filters['city'] ?? null, fn (Builder $q, $city) => $q->where('customers.city', $city)) ->when($filters['from'] ?? null, fn (Builder $q, $from) => $q->whereJalali('orders.created_at', '>=', $from)) ->when($filters['to'] ?? null, fn (Builder $q, $to) => $q->whereJalali('orders.created_at', '<=', $to)) ->orderByDesc('orders.created_at') ->paginate(20, $page); } searchOrders([]); // everything, page 1 searchOrders(['status' => 'paid', 'city' => 'تهران']); searchOrders(['q' => 'مریم']); searchOrders(['from' => '1403/05/26', 'to' => '1403/06/02', 'status' => ['paid', 'cancelled']]);
The q filter is wrapped in its own closure on purpose: without it the orWhereLike would escape the group and pull in rows the other filters meant to exclude.
Dashboard counters
$stats = [ 'today' => NDB::table('orders')->whereJalaliToday('created_at')->count(), 'this_week' => NDB::table('orders')->whereJalaliThisWeek('created_at')->count(), 'this_month_revenue' => (int) NDB::table('orders') ->where('status', 'paid') ->whereJalaliThisMonth('created_at') ->sum('total'), 'has_pending' => NDB::table('orders')->where('status', 'pending')->exists(), ];
A sales report grouped into Jalali months
Neither MySQL nor SQLite knows what a Jalali month is, so group by day in SQL — both have a date() function — and fold the days into months in PHP. One query, correct boundaries.
$daily = NDB::table('orders') ->selectRaw('date(created_at) as day, sum(total) as total, count(*) as orders') ->where('status', 'paid') ->whereJalaliYear('created_at', 1403) ->groupByRaw('date(created_at)') ->get(); $byMonth = []; foreach ($daily as $row) { $month = Jalali::fromGregorian($row->day)->format('Y/m'); $byMonth[$month]['total'] = ($byMonth[$month]['total'] ?? 0) + $row->total; $byMonth[$month]['orders'] = ($byMonth[$month]['orders'] ?? 0) + $row->orders; } // ['1403/05' => ['total' => 1010000, ...], '1403/06' => [...], '1403/07' => [...]]
Top customers, by a correlated sub-select
$top = NDB::table('customers', 'c') ->select('c.name') ->selectSub( NDB::table('orders') ->selectRaw('coalesce(sum(total), 0)') ->whereColumn('orders.customer_id', 'c.id') ->where('status', 'paid'), 'revenue' ) ->where('c.is_active', 1) ->orderByDesc('revenue') ->limit(10) ->get(); // [['name' => 'زهرا موسوی', 'revenue' => 825000], ...]
Only customers who ordered this Jalali year
whereExists keeps this to one query, with no join and no duplicate rows to clean up.
$active = NDB::table('customers') ->whereExists(function (Builder $query) { $query->from('orders') ->whereColumn('orders.customer_id', 'customers.id') ->where('status', 'paid') ->whereJalaliYear('created_at', 1403); }) ->orderBy('id') ->pluck('name');
A write batch inside a transaction
insertGetId gives you the new id for the child rows, and the whole thing rolls back together if anything throws.
function placeOrder(int $customerId, array $items): int { return NDB::transaction(function () use ($customerId, $items) { $total = array_sum(array_map(fn ($item) => $item['quantity'] * $item['price'], $items)); $orderId = NDB::table('orders')->insertGetId([ 'customer_id' => $customerId, 'code' => 'ORD-' . str_pad((string) (NDB::table('orders')->max('id') + 1), 4, '0', STR_PAD_LEFT), 'status' => 'pending', 'total' => $total, 'created_at' => Jalali::now()->toGregorianDateTimeString(), ]); NDB::table('order_items')->insert(array_map( fn ($item) => $item + ['order_id' => $orderId], $items )); return $orderId; }); } $orderId = placeOrder(2, [ ['product' => 'هدفون', 'quantity' => 2, 'price' => 150000], ['product' => 'کابل', 'quantity' => 3, 'price' => 20000], ]);
Throwing anywhere inside the closure rolls the whole batch back:
try { NDB::transaction(function () { NDB::table('orders')->insert([...]); throw new RuntimeException('payment gateway refused'); }); } catch (RuntimeException $e) { // nothing was written }
A legacy table that stores Jalali text
issue_date is a varchar holding 1403/05/26. Let the builder work that out and then query it like any other date column.
$invoices = NDB::table('legacy_invoices') ->detectDateStorage('issue_date') ->whereJalaliBetween('issue_date', ['1403/05/26', '1403/06/02']) ->orderBy('issue_date') ->pluck('amount', 'number'); // ['F-901' => 500000, 'F-902' => 320000]
Or state it, and skip the probe:
NDB::table('legacy_invoices') ->dateStorage('issue_date', Jalali::JALALI) ->whereJalaliMonth('issue_date', 1403, 6) ->pluck('number');
Streaming an export
cursor() fetches one row at a time instead of loading the table into memory, and castJalali converts the dates on the way past.
$query = NDB::table('orders') ->join('customers', 'orders.customer_id', '=', 'customers.id') ->select('orders.code', 'orders.total', 'orders.created_at', 'customers.name') ->castJalali('created_at', 'Y/m/d') ->where('orders.status', 'paid') ->orderBy('orders.id'); $handle = fopen('orders.csv', 'w'); foreach ($query->cursor() as $row) { fputcsv($handle, [$row->code, $row->name, $row->created_at, $row->total]); } fclose($handle); // ORD-1001,علی رضایی,1403/05/26,250000
Bulk updates
// Cancel everything still pending from before Mehr NDB::table('orders') ->where('status', 'pending') ->whereJalali('created_at', '<', '1403/07/01') ->update(['status' => 'cancelled', 'archived_at' => Jalali::now()->toGregorianDateTimeString()]); // Adjust a counter without reading it first NDB::table('orders')->where('code', 'ORD-1001')->increment('total', 5000); // Insert a daily rollup, or update it if the day is already there NDB::table('daily_totals')->upsert( [['day' => '1403/05/26', 'total' => 250]], ['day'], ['total'] );
Filling in only the blank columns
A partial record arrives and should top up whatever is still missing, without overwriting anything already there. One statement, one round trip, every value bound:
$blank = fn ($value) => $value === null || trim((string) $value) === ''; NDB::table('vadana') ->where('national_code', $studentId) ->update([ 'city' => NDB::raw( "coalesce(nullif(trim(city), ''), ?, city)", [$blank($city) ? null : $city] ), 'code' => NDB::raw( "coalesce(nullif(trim(code), ''), ?, code)", [$blank($code) ? null : $code] ), // an empty array and the literal string "null" count as blank too 'lessons' => NDB::raw( "coalesce(nullif(nullif(nullif(trim(lessons), ''), '[]'), 'null'), ?, lessons)", [$blank($lessonsJson) ? null : $lessonsJson] ), ]);
coalesce walks left to right: the stored value wins if it is not blank, the incoming value fills the gap, and the trailing column name keeps the row unchanged when neither has anything. The column names are literals you wrote, so nothing here comes from user input.
If you would rather not write SQL, the same thing as three guarded updates:
function fillColumn(string $studentId, string $column, ?string $value, array $alsoBlank = []): int { if ($value === null || trim($value) === '') { return 0; } return NDB::table('vadana') ->where('national_code', $studentId) ->where(function (Builder $query) use ($column, $alsoBlank) { $query->whereNull($column)->orWhereRaw("trim({$column}) = ''"); foreach ($alsoBlank as $blank) { $query->orWhereRaw("trim({$column}) = ?", [$blank]); } }) ->update([$column => $value]); } fillColumn($studentId, 'city', $city); fillColumn($studentId, 'code', $code); fillColumn($studentId, 'lessons', $lessonsJson, ['[]', 'null']);
Three round trips instead of one, but each condition is spelled out and the return value tells you which columns were actually filled.
Insert, or fill in only the blanks
A student record arrives repeatedly, sometimes more complete than last time. Insert it if it is new; if it is already there, top up only the columns still empty and leave everything else alone.
The obvious shape is insert inside a try, catching the duplicate and updating in the catch. Two things go wrong with that. QueryException is not a PDOException, so catch (PDOException $e) never fires. And even correctly caught, there is a window between the failed insert and the update in which another process can write the same row.
upsert() does the whole thing in one statement:
$blank = fn ($value) => $value === null || trim((string) $value) === ''; $student = [ 'name' => $name, 'national_code' => $studentId, 'student_code' => $studentCode, 'code' => $code, 'city' => $city, 'lessons' => $lessonsJson, ]; NDB::table('vadana')->upsert( [$student], ['national_code'], // the unique index that decides "already there" [ // what to do when it is 'city' => NDB::raw( "coalesce(nullif(trim(city), ''), ?, city)", [$blank($city) ? null : $city] ), 'code' => NDB::raw( "coalesce(nullif(trim(code), ''), ?, code)", [$blank($code) ? null : $code] ), 'lessons' => NDB::raw( "coalesce(nullif(nullif(nullif(trim(lessons), ''), '[]'), 'null'), ?, lessons)", [$blank($lessonsJson) ? null : $lessonsJson] ), ] );
The third argument lists the only columns an existing row may have written to, so name and student_code are used on insert and ignored on conflict. Each coalesce keeps the stored value when it is not blank, falls back to the incoming one, and leaves the row unchanged when neither has anything.
MySQL needs a unique index on national_code; SQLite needs the same, and uses the column list to find it.
When a write genuinely cannot be saved — a missing NOT NULL, a bad foreign key — that is still an exception, and worth keeping:
try { // the upsert above } catch (QueryException $e) { file_put_contents( __DIR__ . '/failed_inserts.jsonl', json_encode($student + ['error' => $e->getMessage()], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL, FILE_APPEND | LOCK_EX ); }
The same thing for thousands of rows
The single-row version binds the incoming value with ?. A batch cannot: one placeholder would be one value for every row in the statement. The update clause has to name the incoming value instead, which is what incoming() gives you — values(col) on MySQL, excluded.col on SQLite, evaluated per row.
use Or81\Eloquent\NDB; /** What counts as blank for a column. */ function blankChain(string $expression, array $alsoBlank = []): string { $sql = "trim({$expression})"; foreach (array_merge([''], $alsoBlank) as $blank) { $sql = "nullif({$sql}, '" . str_replace("'", "''", $blank) . "')"; } return $sql; } /** Keep what is stored, else take what came in, else leave the row alone. */ function fillBlank(string $column, array $alsoBlank = []) { return NDB::raw(sprintf( 'coalesce(%s, %s, %s)', blankChain($column, $alsoBlank), blankChain((string) NDB::incoming($column), $alsoBlank), $column )); } function saveStudents(array $students, int $size = 500): int { $written = 0; foreach (array_chunk($students, $size) as $batch) { $written += NDB::table('vadana')->upsert($batch, ['national_code'], [ 'city' => fillBlank('city'), 'code' => fillBlank('code'), 'lessons' => fillBlank('lessons', ['[]', 'null']), ]); } return $written; }
saveStudents([ ['name' => 'علی رضایی', 'national_code' => '001', 'student_code' => 'S-1', 'code' => '', 'city' => null, 'lessons' => '[]'], ['name' => 'مریم احمدی', 'national_code' => '002', 'student_code' => 'S-2', 'code' => 'B-2', 'city' => 'اصفهان', 'lessons' => '["شیمی"]'], // ... thousands more ]);
Every row is judged on its own values inside one statement: a row whose stored city is blank takes the incoming one, a row that already has a city keeps it, and a blank incoming value cannot wipe anything because it is put through the same nullif chain.
Each chunk is one round trip. 500 rows of six columns is 3000 placeholders, comfortably inside MySQL's limit; lower it if your rows are wider. A chunk is all-or-nothing, so a single bad row rejects its whole batch — wrap the call in a try/catch and retry that chunk row by row if you need to isolate the offender.
Walking a large table
NDB::table('orders')->orderBy('id')->chunk(500, function (array $rows, int $page) { foreach ($rows as $row) { // ... } });
Testing
The package ships its own suite, with no PHPUnit or other dependency to install:
php tests/run.php
batch_upsert_test 25 passed 0 failed
builder_api_test 35 passed 0 failed
compile_test 126 passed 0 failed
conflict_test 31 passed 0 failed
connection_test 74 passed 0 failed
edge_cases_test 20 passed 0 failed
env_test 40 passed 0 failed
examples_test 28 passed 0 failed
grammar_test 66 passed 0 failed
jalali_api_test 29 passed 0 failed
jalali_calendar_test 97 passed 0 failed
jalali_query_test 122 passed 0 failed
model_api_test 72 passed 0 failed
model_test 160 passed 0 failed
raw_bindings_test 32 passed 0 failed
row_test 115 passed 0 failed
streaming_test 20 passed 0 failed
where_variants_test 47 passed 0 failed
----------------------------------------------------------
total 1139 passed 0 failed
Every public method of every class is exercised.
Run one group, or show every assertion:
php tests/run.php jalali
php tests/run.php -v
A single suite is a plain script, so it can be run on its own:
php tests/jalali_calendar_test.php
Every suite uses an in-memory SQLite database and runs in its own process, so nothing has to be set up and nothing leaks between them. What each one covers:
| Suite | Covers |
|---|---|
compile_test |
SQL generation for both drivers, then the same queries against a real database |
builder_api_test |
the reading, writing and aggregate methods end to end |
where_variants_test |
every or* / not* clause, having, joins, unions, locks, debug output |
edge_cases_test |
operator validation, binding order, state isolation, empty and boundary inputs |
grammar_test |
identifier wrapping, every compile method, Expression, QueryException |
connection_test |
raw statements, transactions and savepoints, the query log, named connections |
env_test |
.env parsing, precedence, per-connection prefixes, configure() overrides |
jalali_calendar_test |
the calendar maths against known dates, formatting, arithmetic, the Persian week |
jalali_query_test |
every Jalali clause against all four column shapes |
jalali_api_test |
the Jalali examples in this file |
model_test |
casts, mass assignment, timestamps, soft deletes, scopes, relations |
model_api_test |
the model examples in this file |
row_test |
results as objects, and the create/find helpers on both builders |
raw_bindings_test |
raw fragments carrying bindings, and the fill-in-the-blanks update |
conflict_test |
telling one failure from another, and insert-or-fill-blanks |
batch_upsert_test |
many rows per statement, each judged on its own values |
streaming_test |
reading a large result set without holding it all at once |
reconnect_test |
dropped connections: when a statement is retried, and when it is not |
examples_test |
the worked examples in this file |
Classes
| Class | Role |
|---|---|
NDB |
Entry point: configuration, connections, raw statements, transactions |
Model |
Eloquent-style base model: casts, timestamps, soft deletes, scopes |
ModelBuilder |
The query a model hands out; returns models instead of rows |
Builder |
The fluent query builder |
Row |
One result row, read as properties or as an array |
JoinClause |
The ON clause of a join; a Builder with on() / orOn() |
Jalali |
An immutable Jalali date, plus the calendar maths and storage detection |
Grammar |
Compiles a builder into SQL for the active driver |
Connection |
PDO wrapper: lazy connect, bindings, transactions, reconnects, query log |
Env |
Reads .env, with phpdotenv when it is installed |
Str |
The name conversions behind table and accessor resolution |
Expression |
A raw SQL fragment |
QueryException |
A failed statement, with its SQL and bindings |
RecordNotFoundException |
Thrown by the builder's findOrFail(), firstOrFail() and sole() |
ModelNotFoundException |
The same thing from a model; extends RecordNotFoundException |
Upgrading from the old DB class
src/DB.php has been removed and replaced by NDB. The differences that matter:
- Values are bound, not interpolated. The old
where()built... '$value'directly into the SQL. - Configuration is external. Credentials are passed to
NDB::configure()instead of being hardcoded as private properties. update()works. The old one mixed?placeholders with named bindings and never executed.insertMultiple()is gone;insert()takes either one row or a list of rows, and writes all of them. The old version bound every row but executed once, so only the last survived.- Failures throw
QueryExceptioninstead of callingdie(). insert()returnsbool; useinsertGetId()when you need the new id.update()anddelete()return the affected row count instead ofvoid.get()returns[]rather thanfalsewhen nothing matches;first()returnsnull.- The global
dd()function is gone. Use$query->dump()/$query->dd().
Automatic table naming is still there, and now handles BlogPost -> blog_posts, Person -> people and Category -> categories.
Rows became objects
get(), first(), find() and cursor() used to hand back associative arrays and now hand back Row objects. Row implements ArrayAccess, IteratorAggregate, Countable and JsonSerializable, so the things usually done with those arrays keep working unchanged:
$row['name']; // still fine foreach ($row as $col => $val) // still fine count($row); // still fine json_encode($row); // still fine
Two things do change:
- A strict comparison against an array (
$rows === [['id' => 1]]) no longer holds. Call->toArray(), or ask the query fortoBase(). - Functions that require a real array —
array_column($rows, 'name'),array_mapover the columns of one row — needtoBase()ortoArray()first.
pluck(), value(), the aggregates, and Connection::select() were never rows and are untouched.
License
MIT. See LICENSE.