ristocloud-group / php-activerecord
php-activerecord is an open source ORM library based on the ActiveRecord pattern.
Package info
github.com/ristocloud-group/php-activerecord
pkg:composer/ristocloud-group/php-activerecord
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.68
- monolog/monolog: ^3.0
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^12.0
- predis/predis: ^2.0
Suggests
- ext-memcached: Enables the memcache:// schema cache adapter
- predis/predis: Enables the redis:// schema cache adapter (compatible with Redis 6/7/8 and Valkey 7/8/9)
Provides
None
Conflicts
None
Replaces
None
README
This is a fork maintained by Ristocloud Group S.r.l.
It is based on
zamzar/php-activerecord(itself a fork of the originaljpfuentes2/php-activerecord). We vendor it into our own applications and maintain it — fixing bugs and keeping it running on modern PHP and database versions. It is not affiliated with, nor endorsed by, the original authors.
Originally created by:
- @kla - Kien La
- @jpfuentes2 - Jacques Fuentes
- and these contributors
Upstream documentation: http://www.phpactiverecord.org/
Introduction
A brief summarization of what ActiveRecord is:
Active record is an approach to access data in a database. A database table or view is wrapped into a class, thus an object instance is tied to a single row in the table. After creation of an object, a new row is added to the table upon save. Any object loaded gets its information from the database; when an object is updated, the corresponding row in the table is also updated. The wrapper class implements accessor methods or properties for each column in the table or view.
More details can be found here.
This implementation is inspired and thus borrows heavily from Ruby on Rails' ActiveRecord. We have tried to maintain their conventions while deviating mainly because of convenience or necessity. Of course, there are some differences which will be obvious to the user if they are familiar with rails.
Minimum Requirements
- PHP 8.3+ (tested on PHP 8.3, 8.4 and 8.5)
- PDO driver for your respective database
Supported Databases
- MySQL — the primary production target. Supported minimum: MySQL 8+.
- MariaDB — supported minimum: MariaDB 10.11+.
- PostgreSQL
- SQLite
These are policy minimums; composer.json carries no database-version constraint. Continuous integration runs the full test suite across PHP 8.3, 8.4 and 8.5 against MySQL 8.4 & 9.7, MariaDB 10.11, 11.4, 11.8 & 12.3, PostgreSQL 15, 16, 17 & 18, and SQLite 3. The Oracle (oci) adapter was removed in v1.8.0.
Features
- Finder methods
- Dynamic finder methods
- Writer methods
- Relationships
- Validations
- Callbacks
- Serializations (json/xml)
- Transactions
- Support for multiple adapters
- Miscellaneous options such as: aliased/protected/accessible attributes
Installation
This fork is not published on Packagist. Install it with Composer from its Git repository — add a VCS repository to your composer.json and require the package by its name (ristocloud-group/php-activerecord):
{
"repositories": [
{ "type": "vcs", "url": "https://github.com/ristocloud-group/php-activerecord" }
],
"require": {
"ristocloud-group/php-activerecord": "dev-master"
}
}
Setup is very easy and straight-forward. There are essentially only two configuration points you must concern yourself with:
- Configuring your database connections.
- Setting the database connection to use for your environment.
Example:
ActiveRecord\Config::initialize(function (ActiveRecord\Config $cfg) { $cfg->set_connections([ 'development' => 'mysql://username:password@localhost/development_database_name', 'test' => 'mysql://username:password@localhost/test_database_name', 'production' => 'mysql://username:password@localhost/production_database_name', ]); });
Alternatively (without a closure):
$cfg = ActiveRecord\Config::instance(); $cfg->set_connections([ 'development' => 'mysql://username:password@localhost/development_database_name', 'test' => 'mysql://username:password@localhost/test_database_name', 'production' => 'mysql://username:password@localhost/production_database_name', ]);
MariaDB uses the same mysql:// connection scheme (and the MySQL adapter) as MySQL.
Connection URLs
Connections are configured with URLs of the form:
protocol://username:password@host[:port]/dbname
- Protocols:
mysql://(MySQL and MariaDB),pgsql://(PostgreSQL) andsqlite://. The Oracle adapter was removed in v1.8.0 — anoci://URL throws aDatabaseException. - Port is optional and defaults to the adapter's standard port (3306 for MySQL/MariaDB, 5432 for PostgreSQL).
- Unix sockets: pass the socket path in place of the host:
mysql://user:pass@unix(/var/run/mysqld/mysqld.sock)/dbname.
The query string may carry two optional parameters, separated by & as usual:
charset=<charset>— character set for the connection, applied withSET NAMESright after connecting. Supported by MySQL/MariaDB and PostgreSQL; SQLite does not support it and throws.decode=true— URL-decode the username and password before connecting. Use it when credentials contain characters that are reserved in URLs (@,:,/, …): percent-encode them and adddecode=true.
$cfg->set_connections([ 'development' => 'mysql://username:password@localhost/database_name?charset=utf8mb4', // password is "p@ss:word", percent-encoded because of decode=true 'production' => 'mysql://username:p%40ss%3Aword@db.example.com:3307/database_name?charset=utf8mb4&decode=true', ]);
SQLite needs no credentials or database name — the URL is a path to an existing database file:
sqlite://file.db relative to the current working directory
sqlite://../relative/path/to/file.db
sqlite://unix(/absolute/path/to/file.db) absolute path on Unix
sqlite://windows(c%3A/absolute/path/to/file.db) absolute path on Windows (drive colon percent-encoded)
PHP ActiveRecord will default to use your development database. For testing or production, you simply set the default connection according to your current environment ('test' or 'production'):
ActiveRecord\Config::initialize(function (ActiveRecord\Config $cfg) { $cfg->set_default_connection('production'); // 'development', 'test', or 'production' });
Once you have configured these settings you are done. ActiveRecord takes care of the rest for you. It does not require that you map your table schema to yaml/xml files. It will query the database for this information and cache it so that it does not make multiple calls to the database for a single schema.
Optional: strict mass assignment
By default, mass assignment (new Model($attributes), set_attributes(), update_attributes(), create()) drops
every attribute blocked by a model's $attr_accessible / $attr_protected and, if a logger is configured, logs a
warning. Turn on strict mode to throw an ActiveRecord\MassAssignmentException instead — raised before anything is
assigned, its message lists every blocked attribute. It is global and off by default; assigning a single attribute
($model->name = …) is not affected. The foreign key that association builders (build_* / create_*) inject is
assigned directly, like Rails: it needs no $attr_accessible entry and is never dropped or reported. The attributes
passed to a builder are guarded as usual, and that includes a foreign key passed there with a value: the builder then
injects nothing for it, so a blocked one is dropped (the record gets no foreign key) or, in strict mode, throws. A
foreign key passed as null counts as not passed and is replaced by the injected value.
ActiveRecord\Config::initialize(function (ActiveRecord\Config $cfg) { $cfg->set_strict_mass_assignment(true); });
Optional: caching the schema
php-activerecord introspects each table's schema (columns, types, primary key) from the database. Within a single request this is kept in memory, but PHP's shared-nothing model means it is re-introspected on every request. To persist it across requests, configure an external cache. Three backends are bundled:
Memcached — requires the memcached PHP extension:
$cfg->set_cache('memcache://localhost:11211', ['expire' => 120, 'namespace' => 'my_app']);
File — a filesystem cache (added by this fork for hosts without memcached); no extension required:
$cfg->set_cache('file:///var/tmp/php-activerecord-cache');
The file backend stores one serialized file per cache key inside the directory you pass (creating the directory if it does not exist), and reads it back with unserialize().
The file backend also honors the expire option: each entry stores an expiry timestamp
and is treated as a miss once it lapses (deleted lazily on the next read). Writes are atomic
(temp file + rename). Behavior change: because the default expire is 30 seconds, file
entries that previously persisted forever now expire after 30s by default — pass
['expire' => 0] to keep entries until you flush() them. Files written by older
versions are treated as a miss and regenerated, so no manual purge is needed when upgrading.
Redis — requires the predis/predis Composer package (composer require predis/predis); no PHP extension needed:
$cfg->set_cache('redis://localhost:6379/0', ['expire' => 120, 'namespace' => 'my_app']);
Connection parameters are taken from the DSN, including its query string, so any Predis connection parameter is reachable — e.g. TLS and tuning:
$cfg->set_cache('redis://user:secret@redis.example.com:6379/0?read_write_timeout=2', [ 'namespace' => 'my_app', ]);
The same redis:// DSN targets Redis 6/7/8 and Valkey 7/8/9 interchangeably; the adapter
is exercised against all six in CI. Values are serialized on write and unserialized on read.
ActiveRecord\Cache::flush() deletes only the keys under the configured namespace
(via SCAN/DEL); with no namespace it falls back to FLUSHDB, which clears the whole
selected Redis database — set a namespace when the Redis instance is shared. Do not pass
a Predis prefix client option for key isolation: Predis does not apply prefix to the plain
SCAN command that namespace-scoped flush() relies on, so keys end up stored under
prefix + key while flush() only matches namespace::*, silently deleting nothing — use the
namespace option instead, which flush() already understands.
ActiveRecord\Cache::flush() invalidates the cache for any backend (for the file cache it
deletes the cached files) — for example after running a schema migration. All backends accept
a namespace option that prefixes every cache key, useful when several applications share one
cache store.
The cache is lock-free: at each expiry, concurrent requests all recompute the cached value
once (a brief stampede). For very hot deployments raise expire (or set it to 0). Prefer a
local filesystem for the file backend — TTLs rely on the host clock, so shared storage (NFS)
across clock-skewed hosts can expire entries early or late.
| Backend | Requirement | TTL (expire) |
Persistence | Namespace / flush | Concurrency | Best for |
|---|---|---|---|---|---|---|
| Memcached | memcached PHP extension |
Yes (server-side) | In-memory, evictable | namespace prefix; flush() clears the whole server |
Atomic server-side TTL | Existing memcached infra |
| File | none | Yes (since this fork) | On disk until expiry/flush | namespace prefix; flush() deletes files |
Lock-free; atomic writes, lazy GC, local-FS assumption | Single host, no extra services |
| Redis / Valkey | predis/predis package |
Yes (server-side) | In-memory (optionally persisted by the server) | namespace-scoped SCAN/DEL, else FLUSHDB |
Atomic server-side TTL | Shared/networked cache, HA |
Hash conditions
In a conditions hash (['conditions' => ['name' => 'Tito']], also for update_all / delete_all) every key is a
column name, always quoted as identifiers and never spliced in as SQL. A key may name its table ('books.name', e.g.
with joins), and a partly quoted name is quoted part by part ('`books`.name' → `books`.`name`); with
joins an unqualified key gets the model's table. Keys that name the same column ('id', '`id`', 'books.id',
and in find / all / first / last an $alias_attribute name and its column) are all kept and ANDed. An
attribute name whose column is named differently is mapped to that column, also for update_all / delete_all (and
for the keys of an update_all set hash and of upsert): 'space_name' → space name, 'dash_name' →
dash-name, and on Postgres, whose quoted names are case-sensitive, a mixed-case 'some_date' → some_Date (MySQL
and SQLite match it as is); space and hyphen differences are mapped on every adapter, and upsert's
$unique_by / $update names are mapped too. Written next to the column's own name, two conditions are kept and
ANDed, whereas in a set hash or an upsert row the later key wins. An
expression such as 'LOWER(name)' => 'tito' is one (unknown) column name, not a function call, so the database rejects
it: write expressions as a positional condition instead, ['conditions' => ['LOWER(name) = ?', 'tito']].
The identifiers-only guarantee relies on doubling the quote character, so it assumes a connection charset in which
that byte cannot belong to a multibyte character: utf8mb4 / utf8 (the default) or a single-byte charset. Legacy
multibyte MySQL charsets (gbk, big5, sjis / cp932, gb18030) are not covered.
Examples
Rather than inline snippets, every major feature is shown as a runnable,
self-contained example under examples/. Each creates its own
SQLite database and runs with no setup:
php examples/simple/simple.php
| Example | Demonstrates |
|---|---|
simple/ |
Basic CRUD (find/first/create/update/delete), the primary key create() fills in (an int, as find() gives it), tables without a primary key, the never-saved- and deleted-record update/delete guard, and convention overrides ($table_name, $primary_key) |
finders/ |
Dynamic finders, the conditions/order/limit/offset/group/having/select options, last() (reversed order), find_by_sql, static scopes, find('abc') → RecordNotFound (on Postgres without querying), update_all/delete_all with order + limit (emulated on Postgres with a primary-key subquery) |
validations/ |
$validates_* macros, a custom validate(), the Errors object |
relationships/ |
belongs_to (incl. class_name/foreign_key options), has_many, has_one, has_many … through, eager include, create_* builders, composite-key has_many with declared conditions, an OR in declared conditions stays scoped to the owner, a has_many keyed by a declared primary_key (lazy load, eager include and create_* agree, a declared limit applies per owner) |
callbacks/ |
Lifecycle hooks and halting a save |
attributes/ |
Custom get_*/set_*, $alias_attribute, $attr_accessible (and strict mass assignment), $delegate, dirty tracking, integers beyond PHP_INT_MAX, columns whose names are not attribute names ("first name" is first_name) |
serialization/ |
to_json / to_xml / to_array with only/except/methods/include |
upsert/ |
Model::upsert() — bulk insert-or-update with unique_by/update and managed timestamps |
orders/ |
A fuller app combining validations, a callback, and relationships |
inflection/ |
Table names derived from class names (Human → humans, Fireman → firemen, Complex → complexes, MatrixRow → matrix_rows), the has_many builders that follow, and $table_name as the escape hatch |
See examples/README.md for the full index.
Contributing
Please refer to CONTRIBUTING.md for information on how to contribute to this fork.
License
MIT — see LICENSE.