ndtan/dbf

Single-file PHP Database Framework — secure, PRO & Enterprise+ features.

v0.1.0 2025-08-25 05:50 UTC

This package is auto-updated.

Last update: 2025-09-25 06:44:57 UTC


README

NDT DBF logo

NDT DBF — A single‑file PHP Database Framework (PRO · Enterprise+).
Secure by default, compact API, works as one file or via Composer/PSR‑4.

Website & Docs · Download single file · Donate

Build Status Total Downloads Latest Stable Version License

Table of Contents

Features

  • ⚡️ Lightweight & single file: no third-party deps (PDO only)
  • 🔐 Secure by default: prepared statements, per-driver identifier quoting, IN-list guard
  • 🧱 Query Builder: select / where / orWhere / whereIn / between / null / join / group / having / order / limit / offset
  • 🧾 Full CRUD: insert, insertMany, insertGet (PG/SQLite RETURNING)
  • 🔁 Cross-dialect Upsert: MySQL (ON DUP KEY), PG/SQLite (ON CONFLICT), safe fallback
  • 💳 Transactions: exponential backoff + jitter on deadlock
  • 🚦 Readonly/Maintenance mode: block DML when system is frozen
  • 🪶 Soft Delete: withTrashed() / onlyTrashed() / restore() / forceDelete()
  • 🧭 Master/Replica routing: auto/manual read-write split
  • ⏱️ Per-query timeout (best-effort for MySQL/PG)
  • 🧩 Middleware & Policy hooks + Logger & Metrics hook
  • 📊 Aggregates & Sugar: sum(), avg(), min(), max(), pluck()
  • 🧪 Test Mode: build SQL without executing (great for unit tests/previews)
  • 📚 Keyset pagination helper

Supports MySQL/MariaDB, PostgreSQL, SQLite, SQL Server.

Requirements

  • PHP >= 8.1
  • Extension: PDO (+ respective drivers: pdo_mysql, pdo_pgsql, pdo_sqlite, pdo_sqlsrv)

Installation

Composer

composer require ndtan/dbf
<?php
require 'vendor/autoload.php';

$db = new \ndtan\DBF([
  'type'     => 'mysql',
  'host'     => '127.0.0.1',
  'database' => 'app',
  'username' => 'root',
  'password' => 'secret',
  'charset'  => 'utf8mb4',
  'logging'  => false,
]);

Single file

Copy src/DBF.php into your project:

<?php
require __DIR__ . '/DBF.php';

$db = new \ndtan\DBF('mysql://root:secret@127.0.0.1/app?charset=utf8mb4');

You may also use ENV: set NDTAN_DBF_URL and call new \ndtan\DBF().

Connection Setup

URI/DSN (one line)

$db = new \ndtan\DBF('pgsql://user:pass@localhost:5432/app');
$db = new \ndtan\DBF('sqlite:///:memory:');        // memory
$db = new \ndtan\DBF('sqlite:///path/to/app.db');  // file
$db = new \ndtan\DBF('sqlsrv://sa:pass@localhost/app');

Array config

$db = new \ndtan\DBF([
  'type'     => 'mysql',           // mysql|pgsql|sqlite|sqlsrv
  'host'     => 'localhost',
  'database' => 'app',
  'username' => 'root',
  'password' => 'secret',
  'charset'  => 'utf8mb4',
  'collation'=> 'utf8mb4_general_ci',
  'prefix'   => 'app_',
  'logging'  => true,
  'error'    => PDO::ERRMODE_EXCEPTION,
  'option'   => [PDO::ATTR_CASE => PDO::CASE_NATURAL],

  // Enterprise options
  'readonly' => false,
  'features' => [
    'soft_delete'   => ['enabled'=>true,'column'=>'deleted_at','mode'=>'timestamp'],
    'max_in_params' => 1000
  ],
  'command'  => ['SET SQL_MODE=ANSI_QUOTES'],
]);

Existing PDO

$pdo = new PDO('mysql:host=127.0.0.1;dbname=app;charset=utf8mb4','root','secret',[
  PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
  PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
  PDO::ATTR_EMULATE_PREPARES => false,
]);

$db = new \ndtan\DBF(['pdo'=>$pdo, 'type'=>'mysql']);

Master/Replica

$db = new \ndtan\DBF([
  'write'   => 'mysql://root:secret@10.0.0.10/app',
  'read'    => 'mysql://reader:secret@10.0.0.11/app',
  'routing' => 'auto', // auto|manual|single
  'prefix'  => 'app_',
]);
// Manual route when needed:
$one = $db->using('read')->table('users')->first();

Quick Start

$users = $db->table('users')
  ->select(['id','email'])
  ->where('status','=','active')
  ->orderBy('id','desc')
  ->limit(20)
  ->get();

Query Builder

Basic select

$db->table('users')->get();              // SELECT * FROM users
$db->table('users')->first();            // fetch one row
$db->table('users')->exists();           // boolean
$db->table('users')->count();            // COUNT(*)

Where conditions

$db->table('users')->where('status','=','active')->get();
$db->table('users')->where('id','>',100)->orWhere('email','=','a@ndtan.net')->get();
$db->table('users')->whereBetween('created_at',['2025-01-01','2025-12-31'])->get();
$db->table('users')->whereIn('id',[1,2,3])->get();   // guarded IN list
$db->table('users')->whereNull('deleted_at')->get();

Join / Group / Having / Order / Limit

$db->table('orders')
  ->select(['orders.id','users.email','SUM(total) AS sum_total'])
  ->join('users','orders.user_id','=','users.id')
  ->groupBy(['orders.user_id'])
  ->having('SUM(total)','>',100)
  ->orderBy('orders.id','desc')
  ->limit(50)->offset(0)
  ->get();

CRUD

// Insert
$id = $db->table('users')->insert(['email'=>'a@ndtan.net','status'=>'active']);

// Insert many
$db->table('order_items')->insertMany([
  ['order_id'=>1,'sku'=>'A','qty'=>1],
  ['order_id'=>1,'sku'=>'B','qty'=>2],
]);

// Insert + RETURNING (PG/SQLite) or re-fetch by id (MySQL/SQLSrv)
$row = $db->table('users')->insertGet(['email'=>'b@ndtan.net','status'=>'vip'], ['id','email']);

// Update
$aff = $db->table('users')->where('id','=', $id)->update(['status'=>'vip']);

// Delete (soft delete if enabled)
$aff = $db->table('users')->where('id','=', $id)->delete();

Upsert

// MySQL: ON DUPLICATE KEY; PG/SQLite: ON CONFLICT; others: safe fallback (tx)
$db->table('users')->upsert(
  ['email'=>'a@ndtan.net','status'=>'vip'],
  conflict: ['email'],
  updateColumns: ['status']
);

Aggregates & Pluck

$sum = $db->table('orders')->sum('total');
$avg = $db->table('orders')->avg('total');
$min = $db->table('orders')->min('total');
$max = $db->table('orders')->max('total');

$emails = $db->table('users')->pluck('email');          // ['a@x','b@y',...]
$map    = $db->table('users')->pluck('email','id');     // [1=>'a@x', 2=>'b@y', ...]

Keyset pagination

$page1 = $db->table('orders')->orderBy('id','desc')->limit(50)->getKeyset(null,'id');
$page2 = $db->table('orders')->orderBy('id','desc')->limit(50)->getKeyset($page1['next'],'id');

Enterprise+ Features

Transactions + Deadlock retry

$db->tx(function(\ndtan\DBF $tx) {
  $orderId = $tx->table('orders')->insert(['user_id'=>10,'total'=>100,'created_at'=>date('c')]);
  $tx->table('order_items')->insert(['order_id'=>$orderId,'sku'=>'A','qty'=>1]);
}, attempts: 3); // auto-retry on deadlock

Readonly/Maintenance mode

$db->setReadonly(true);
// any DML (INSERT/UPDATE/DELETE) will be blocked

Soft Delete guard

// Enable via config: features.soft_delete.enabled=true
$db->table('users')->where('id','=',123)->delete();     // set deleted_at
$db->table('users')->withTrashed()->first();            // include soft-deleted
$db->table('users')->onlyTrashed()->get();              // only soft-deleted
$db->table('users')->where('id','=',123)->restore();    // restore
$db->table('users')->where('id','=',123)->forceDelete();// hard delete

Middleware / Policy / Metrics

// Middleware: audit
$db->use(function(array $ctx, callable $next) {
  $start = microtime(true);
  $res = $next($ctx);
  error_log('SQL '.$ctx['type'].' took '.round((microtime(true)-$start)*1000,2).'ms');
  return $res;
});

// Policy: block UPDATE on users
$db->policy(function(array $ctx){
  if (($ctx['table'] ?? null)==='users' && ($ctx['action'] ?? '')==='update') {
    throw new RuntimeException('Policy blocked');
  }
});

// Metrics hook
$db->setMetrics(function(array $m){
  // $m = ['type'=>'select','table'=>'users','ms'=>12.3,'count'=>10, ...]
});

Per-query timeout

$db->table('big_table')->timeout(1500)->get(); // 1.5s best-effort (MySQL/PG)

Test Mode

$db->setTestMode(true);
// Queries are not executed; SQL & params are still built
$rows = $db->table('users')->where('status','=','active')->limit(10)->get(); // []
echo $db->queryString();
print_r($db->queryParams());

Raw SQL

// Positional
$rows = $db->raw('SELECT * FROM users WHERE email LIKE ?', ['%ndtan.net']);

// Named
$rows = $db->raw('SELECT * FROM users WHERE email = :e', ['e'=>'a@ndtan.net']);

// DML
$aff  = $db->raw('UPDATE users SET status=? WHERE id=?', ['vip', 10]);

Security

  • Prepared statements for all user inputs
  • Identifier quoting per driver to prevent injection via table/column names
  • IN-list guard: limit whereIn items (default 1000)
  • Readonly mode to block unintended writes
  • Scope/Policy hooks for tenant/RBAC-like checks

DBF is “secure by default”, but you should still harden your DB (users/roles, TLS, firewall, backups, monitoring).

Debugging & Logging

$db->setLogger(function(string $sql, array $params, float $ms) {
  error_log(sprintf('[SQL %.2fms] %s | %s', $ms, $sql, json_encode($params)));
});

$info = $db->info(); // driver, routing, readonly, soft_delete, test_mode...

Tests & CI

Run tests (SQLite :memory:):

composer install
vendor/bin/phpunit

The template repo ships with GitHub Actions (.github/workflows/ci.yml) running PHPUnit on PHP 8.1–8.4.

Contributing

PRs/issues are welcome! Please:

  1. Fork → create a feature branch
  2. Add tests for your change
  3. Ensure vendor/bin/phpunit passes
  4. Describe your change clearly in the PR

License

MIT © Tony Nguyen

Donate

If DBF helps your work, consider buying the author a coffee ☕️
👉 PayPal: https://www.paypal.com/paypalme/copbeo