Freshsauce\Model\Model gives you the sweet spot between raw PDO and a full framework ORM: fast setup, familiar model-style workflows, and complete freedom to drop to SQL whenever you want.
If you want database-backed PHP models without pulling in a heavyweight stack, this library is built for that job.
Use this library when you want:
- Active-record style models without adopting a full framework ORM
- Direct access to SQL and PDO when convenience helpers stop helping
- A small API surface that stays easy to understand in legacy or custom PHP apps
Skip it if you need relationship graphs, migrations, or a chainable query builder comparable to framework ORMs.
- Lightweight by design: point a model at a table and start reading and writing records.
- PDO-first: keep the convenience methods, keep full access to SQL, keep control.
- Framework-agnostic: use it in custom apps, legacy codebases, small services, or greenfield projects.
- Productive defaults: CRUD helpers, dynamic finders, counters, hydration, and timestamp handling are ready out of the box.
- Practical opt-ins: transaction helpers, configurable timestamp columns, and attribute casting stay lightweight but cover common app needs.
- Portable across databases: exercised against MySQL/MariaDB, PostgreSQL, and SQLite.
composer require freshsauce/modelRequirements:
- PHP
8.3+ ext-pdo- A PDO driver such as
pdo_mysqlorpdo_pgsql
Create a table. This example uses PostgreSQL syntax:
CREATE TABLE categories (
id SERIAL PRIMARY KEY,
name VARCHAR(120) NULL,
updated_at TIMESTAMP NULL,
created_at TIMESTAMP NULL
);If you are using MySQL or MariaDB, use INT AUTO_INCREMENT PRIMARY KEY for id instead.
Connect and define a model:
require_once 'vendor/autoload.php';
Freshsauce\Model\Model::connectDb(
'pgsql:host=127.0.0.1;port=5432;dbname=categorytest',
'postgres',
'postgres'
);
class Category extends Freshsauce\Model\Model
{
protected static $_tableName = 'categories';
}Create, read, update, and delete records:
$category = new Category([
'name' => 'Sci-Fi',
]);
$category->save();
$loaded = Category::getById($category->id);
$loaded->name = 'Science Fiction';
$loaded->save();
$loaded->delete();That is the core promise of the library: minimal ceremony, direct results.
Use the docs based on how much detail you need:
- docs/guide.md for setup, model definition, CRUD, querying, validation, strict fields, and database notes
- docs/api-reference.md for method-by-method behavior and return types
- EXAMPLE.md for shorter copy-paste examples
The base model gives you the methods most applications reach for first:
save()insert()update()delete()deleteById()deleteAllWhere()getById()first()last()count()transaction()beginTransaction()commit()rollBack()
If your table includes created_at and updated_at, they are populated automatically on insert and update.
Timestamps are generated in UTC using the Y-m-d H:i:s format. SQLite stores those values as text, while MySQL/MariaDB and PostgreSQL accept them in timestamp-style columns.
Use the built-in transaction helper when several writes should succeed or fail together:
Category::transaction(function (): void {
$first = new Category(['name' => 'Sci-Fi']);
$first->save();
$second = new Category(['name' => 'Fantasy']);
$second->save();
});If you need lower-level control, the model also exposes beginTransaction(), commit(), and rollBack() as thin wrappers around the current PDO connection.
The default convention remains created_at and updated_at, but models can now opt into different column names or disable automatic timestamps entirely:
class AuditLog extends Freshsauce\Model\Model
{
protected static $_tableName = 'audit_logs';
protected static ?string $_created_at_column = 'created_on';
protected static ?string $_updated_at_column = 'modified_on';
}
class LegacyCategory extends Freshsauce\Model\Model
{
protected static $_tableName = 'legacy_categories';
protected static bool $_auto_timestamps = false;
}Cast common fields to application-friendly PHP types:
class Product extends Freshsauce\Model\Model
{
protected static $_tableName = 'products';
protected static array $_casts = [
'stock' => 'integer',
'price' => 'float',
'is_active' => 'boolean',
'published_at' => 'datetime',
'tags' => 'array',
'settings' => 'object',
];
}Supported cast types are integer, float, boolean, datetime, array, and object.
datetime casts assume stored strings are UTC wall-time values. If you do not want implicit timezone conversion by the database, prefer DATETIME-style columns or ensure the connection session timezone is UTC before using TIMESTAMP columns.
Build expressive queries straight from method names:
Category::findByName('Science Fiction');
Category::findOneByName('Science Fiction');
Category::firstByName(['Sci-Fi', 'Fantasy']);
Category::lastByName(['Sci-Fi', 'Fantasy']);
Category::countByName('Science Fiction');Legacy snake_case dynamic methods still work during the transition, but they are deprecated and emit E_USER_DEPRECATED notices.
If you still have legacy calls such as find_by_name(), treat them as migration work rather than the preferred API.
For common read patterns that do not justify raw SQL:
Category::exists();
Category::existsWhere('name = ?', ['Science Fiction']);
$ordered = Category::fetchAllWhereOrderedBy('name', 'ASC');
$latest = Category::fetchOneWhereOrderedBy('id', 'DESC');
$names = Category::pluck('name', '', [], 'name', 'ASC', 10);Use targeted where clauses:
$one = Category::fetchOneWhere('id = ? OR name = ?', [1, 'Science Fiction']);
$many = Category::fetchAllWhere('name IN (?, ?)', ['Sci-Fi', 'Fantasy']);Or run raw SQL directly through PDO:
$statement = Freshsauce\Model\Model::execute(
'SELECT * FROM categories WHERE id > ?',
[10]
);
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);If you change a table schema at runtime and need the model to see the new columns without reconnecting, call YourModel::refreshTableMetadata().
Use instance-aware hooks when writes need application rules:
class Category extends Freshsauce\Model\Model
{
protected static $_tableName = 'categories';
protected function validateForSave(): void
{
if (trim((string) $this->name) === '') {
throw new RuntimeException('Name is required');
}
}
}Use validateForInsert() or validateForUpdate() when the rules differ by operation.
The legacy static validate() method still works for backward compatibility, but new code should prefer the instance hooks.
Unknown properties are permissive by default for compatibility. If you want typo-safe writes, enable strict field mode:
class Category extends Freshsauce\Model\Model
{
protected static $_tableName = 'categories';
protected static bool $_strict_fields = true;
}You can also opt in at runtime with Category::useStrictFields(true).
The library now throws model-specific exceptions for common failure modes instead of generic Exception objects.
Freshsauce\Model\Exception\ConnectionExceptionfor missing database connectionsFreshsauce\Model\Exception\UnknownFieldExceptionfor invalid model fields and dynamic finder columnsFreshsauce\Model\Exception\InvalidDynamicMethodExceptionfor unsupported dynamic static methodsFreshsauce\Model\Exception\MissingDataExceptionfor invalid access to uninitialized model data
MySQL or MariaDB:
Freshsauce\Model\Model::connectDb(
'mysql:host=127.0.0.1;port=3306;dbname=categorytest',
'root',
''
);PostgreSQL:
Freshsauce\Model\Model::connectDb(
'pgsql:host=127.0.0.1;port=5432;dbname=categorytest',
'postgres',
'postgres'
);SQLite is supported in the library and covered by the automated test suite.
Schema-qualified table names such as reporting.categories are supported for PostgreSQL models.
The repository includes:
- PHPUnit coverage for core model behavior
- PHPStan static analysis
- PHP-CS-Fixer formatting checks
- GitHub Actions CI for pushes and pull requests
- Automatic
vYY.MM.DD.nCalVer tags and GitHub releases for pushes tomain
- Need fuller ORM docs? Start with docs/guide.md and docs/api-reference.md.
- Want to see planned improvements? See ROADMAP.md.
- Want fuller usage examples? See EXAMPLE.md.
- Want to contribute? See CONTRIBUTING.md.