ascetic-soft/rowcast
Lightweight DataMapper over PDO for PHP 8.4+. Maps database rows to DTOs and back using reflection, with auto or explicit mapping, type conversion, and a fluent query builder featuring dialect-aware UPSERT.
Requirements first: PHP 8.4+ and ext-pdo. This is the biggest gate — if you're on Laravel 11 with PHP 8.3, you can't install it. Confirm composer.json "php": "^8.4" before anything else.
Install:
composer require ascetic-soft/rowcast
Where to look first:
README.md Quick Start — 15 lines gets you a working read/write.llms.txt (linked in README) — the LLM/Context7 context file; fastest way to give your editor accurate completions.ascetic-soft.github.io/Rowcast) — the README truncates the QueryBuilder OR-condition and pagination sections.agents/AGENTS.md in the repo — tells you the internal code layout (DataMapper orchestrates, Connection wraps PDO, dialects live in src/QueryBuilder/Dialect/). Useful when you need to debug.Minimal first use case — mirror this exact shape before doing anything clever:
use AsceticSoft\Rowcast\Connection;
use AsceticSoft\Rowcast\DataMapper;
class UserDto {
public int $id;
public string $email;
public bool $isActive;
}
$connection = Connection::create('sqlite::memory:');
$mapper = new DataMapper($connection);
$u = new UserDto();
$u->email = '[email protected]';
$u->isActive = true;
$mapper->insert('users', $u);
$found = $mapper->findOne(UserDto::class, ['email' => '[email protected]']);
Note the two different target styles in those two lines: insert takes a table name string, findOne takes a DTO class-string (which derives the table via naming rules). Get comfortable with that asymmetry on day one — it trips everyone up.
Laravel integration (if applicable): register a singleton in a service provider:
$this->app->singleton(DataMapper::class, function ($app) {
$pdo = new \PDO(
config('database.connections.rowcast.dsn'),
config('database.connections.rowcast.username'),
config('database.connections.rowcast.password'),
);
$conn = new Connection($pdo, nestTransactions: true);
return new DataMapper($conn);
});
Do not try to reuse Laravel's DB::connection()->getPdo() unless you're certain of the lifecycle — PDO instances in Laravel are managed per-connection and can be recycled under Octane.
DTO design conventions. Hydration uses reflection against declared public properties. Practical rules:
new).bool, int, float, string, \DateTimeInterface, array, and BackedEnum — those have built-in converters.nullable types liberally; unmapped/missing columns arrive as null.Repository/service wrapper pattern. Don't call DataMapper from controllers. Wrap it:
final class UserRepository
{
public function __construct(
private readonly DataMapper $mapper,
private readonly Mapping $mapping, // built once in constructor
) {}
public function byEmail(string $email): ?UserDto {
return $this->mapper->findOne($this->mapping, ['email' => $email]);
}
public function save(UserDto $u): void {
$this->mapper->upsert($this->mapping, $u, 'email');
}
}
This also solves the Mapping mutability problem — build it once in the constructor and never touch it again.
Auto vs explicit mapping — pick a lane.
Mapping::auto(...) or a bare class-string) — best when your schema already follows snake_case-convention. Zero config, but any column whose name doesn't convert cleanly will be silently ignored. This is a common source of "why is my field always null?" bugs.Mapping::explicit(...)) — best when the schema is legacy or third-party. Only declared columns are read/written. This is the safer default for anything production-facing.$mapping = Mapping::explicit(UserDto::class, 'legacy_users')
->column('id', 'id')
->column('usr_email', 'email')
->column('is_active', 'isActive')
->ignore('internalNote');
Read paths — pick the right method for scale:
findAll — small sets, returns array.iterateAll — large sets; returns iterable (likely generators under the hood). Use this for exports, report generation, mass-migration scripts. Wrap in ->transactional() only for writes; read-only iteration doesn't need it.findOne — single row; returns null if absent.Write paths:
| Need | Use |
|---|---|
| Definitely new row | insert |
| Definitely existing, you know the where | update |
| Either — but you have a unique constraint | upsert |
| Either — no constraint, small volume | save (read-then-write) |
| Bulk, all-new | batchInsert |
| Bulk, mixed | batchUpsert |
upsert is the workhorse. save costs two round-trips and can race; only use it when the DB/schema won't support ON CONFLICT / ON DUPLICATE KEY.
Batch operations and chunking. batchInsert / batchUpsert auto-chunk by bind-param limit (SQLite = 999). Pass an explicit override when the DTO has many columns:
// 30-column DTO on SQLite -> chunk at ~30 rows, not 999/30=33
$mapper->batchInsert('users', $dtos, 30);
The whole batch runs in a single transaction — you don't need to wrap it.
QueryBuilder for non-DTO reads and reporting. Use the fluent builder whenever you need joins, aggregates, or pagination:
$rows = $connection->createQueryBuilder()
->select('u.id', 'COUNT(o.id) AS orders')
->from('users', 'u')
->leftJoin('u', 'orders', 'o', 'o.user_id = u.id')
->setLimit(50)
->setOffset(0)
->fetchAllAssociative();
Advanced where — same engine in DataMapper and QueryBuilder. This is the underrated feature. The array-condition syntax carries operators:
$mapper->findAll(UserDto::class, where: [
'deleted_at' => null,
'$or' => [
['status' => ['active', 'pending']],
['role' => 'admin'],
],
'age >=' => 18,
]);
Operators: >, >=, <, <=, IN, NOT IN, LIKE, NOT LIKE, BETWEEN, plus ILIKE/NOT ILIKE on PostgreSQL only. BackedEnum values auto-normalize to their backing scalar even inside IN arrays — so ['status' => [UserStatus::Active]] just works.
OR composition. Two styles:
// Method-based OR groups
->whereOr(['status' => 'active', 'age >' => 18], ['role' => 'admin'])
// or array-based
->where(['$or' => [...]])
Use whereOr() when you can — it composes cleanly with prior where() calls (it wraps the existing predicate as (prev OR ...)).
Type conversion for value objects. The built-in converters cover scalars, bools, dates, arrays (JSON), and backed enums. For anything else (UUIDs, money, phone numbers), write a TypeConverterInterface and register it:
$converters = TypeConverterRegistry::defaults()->add(new UuidConverter());
$mapper = new DataMapper($connection, typeConverter: $converters);
Register custom converters after defaults() — the registry is ordered and custom ones should override defaults where they collide.
Name conversion. SnakeCaseToCamelCase is the default. If your schema uses prefixes, write a NameConverterInterface — but only do this when auto-mapping; if you're using explicit Mapping, you likely don't need it.
Query events for observability. Hook onBeforeQuery / onAfterQuery to pipe into Laravel's log:
$conn->onAfterQuery(function (string $sql, array $params, float $duration, ?\Throwable $e) {
if ($duration > 0.5) {
Log::warning('Slow query', ['sql' => $sql, 'ms' => $duration * 1000]);
}
});
There's no built-in PSR-3 logger integration — you wire it yourself.
The pluralization trap. Class-string targets derive table names as plural snake_case: UserDto → user_dtos, not users. The README shows User → users and UserProfile → user_profiles, but with a Dto suffix you get user_dtos. Either name your classes without Dto (unusual), or always pass an explicit Mapping / table name. This is the #1 silent failure new users hit.
Mapping is mutable. The README calls this out explicitly: prefer building once during setup and reusing. Never mutate a shared instance after another service owns a reference — it'll change behavior mid-request. Make it readonly in your repository constructor.
Auto-mapping silently drops unmatched columns. If a DB column doesn't convert to a declared property, it's ignored on read; if a property has no matching column, it's ignored on write. There's no validation. Explicit Mapping mode is stricter — only declared columns are touched, which at least tells you what's happening.
save() is read-then-write. Not atomic. Two statements. A race between the SELECT and the INSERT will surface as a unique-constraint violation. Use upsert whenever a conflict constraint exists. save is for when it doesn't.
DateTimeConverter formats as UTC. If your stored timestamps are local-time strings, you'll get silent TZ skew. Either store UTC or write a custom converter.
PostgreSQL-only operators. ILIKE and NOT ILIKE compile on other dialects but will fail at DB level. DialectFactory only recognizes mysql, pgsql, sqlite — anything else (MariaDB via mysql driver is fine, but e.g. SQL Server) falls back to GenericDialect, which disables dialect-specific features.
Empty IN arrays are intentionally "safe". ['status' => []] compiles to 1 = 0 (always false); ['status !=' => []] to 1 = 1. This is correct SQL semantics, but it means an empty user-filter input list won't accidentally return everything. Also worth knowing: if you're building filters dynamically, check emptiness before adding to where.
SQLite bind-parameter ceiling. 999 parameters per statement. Batch methods chunk automatically, but if you're hand-writing a mega-IN (WHERE id IN (2000 values)), you'll hit it. Chunk manually or fall back to whereIn with a subquery.
Nested transactions are opt-in. Default Connection is nestTransactions: false. The README shows nestTransactions: true in Connection::create. If you rely on Laravel's nested transaction behavior, set this — otherwise inner transactional() calls throw.
DataMapper uses reflection — cache warmup matters. First hydration of a DTO class pays reflection cost. For high-throughput endpoints, warm the mapper at boot with a throwaway hydrate() call, or rely on opcache (the package is PHPStan level 9 but doesn't ship a reflection cache in this version).
No migrations, no relationships, no lazy loading. This is deliberately not Eloquent. You bring your own schema (Laravel migrations, Phinx, whatever). Joins are manual. Accessors and observers are out of scope. If you need relationship traversal, either write a repository method that does an explicit join, or keep Eloquent for that model.
No connection pooling or auto-reconnect. If the PDO connection drops, Rowcast throws. For long-running workers (queue jobs, Octane), re-establish the Connection in a job's handle() or listen for connection-loss exceptions and rebuild. Under Octane, treat the singleton DataMapper as you would DB — reset per worker boot.
Debugging: onBeforeQuery for SQL inspection is the fastest path. If a hydration returns nulls, check: (1) column name vs. property name under your name converter, (2) declared property type vs. DB value type (e.g. a string property receiving a DB int will hit ScalarConverter and cast — fine — but a bool property receiving 't'/'f' won't work; the BoolConverter only handles 0/1), (3) nullable handling.
Extension points in order of likely need:
TypeConverterInterface — for value objects (Uuid, Money).NameConverterInterface — for legacy schemas using prefixed columns.Mapping — the sanctioned escape hatch for irregular schemas.Dialect — only if you're on a non-MySQL/PG/SQLite DB and need dialect-specific SQL; this is where DialectFactory is intentionally extensible.Testing in your own app. Rowcast's own suite runs entirely against sqlite::memory: — that's a strong signal for your own tests. Spinning up a Connection::create('sqlite::memory:') per test (with migrations run once) is far faster than MySQL/Postgres test containers, and the QueryBuilder covers the same operator surface.
CI ordering matters when contributing. Per AGENTS.md: cs-check → phpstan → test. The pre-commit hook runs php-cs-fixer fix + git add -u + PHPStan, not PHPUnit — so passing commit ≠ passing CI.
How can I help you explore Laravel packages today?