PHPackages                             ascetic-soft/rowcast - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Database &amp; ORM](/categories/database)
4. /
5. ascetic-soft/rowcast

ActiveLibrary[Database &amp; ORM](/categories/database)

ascetic-soft/rowcast
====================

Lightweight DataMapper and QueryBuilder over PDO with DTO hydration and type casting

v2.0.10(2mo ago)0501MITPHPPHP &gt;=8.4CI passing

Since Feb 11Pushed 2mo agoCompare

[ Source](https://github.com/ascetic-soft/Rowcast)[ Packagist](https://packagist.org/packages/ascetic-soft/rowcast)[ RSS](/packages/ascetic-soft-rowcast/feed)WikiDiscussions master Synced 1mo ago

READMEChangelog (10)Dependencies (6)Versions (18)Used By (1)

Rowcast
=======

[](#rowcast)

[![CI](https://github.com/ascetic-soft/Rowcast/actions/workflows/ci.yml/badge.svg)](https://github.com/ascetic-soft/Rowcast/actions/workflows/ci.yml)[![codecov](https://camo.githubusercontent.com/60c3b82d119102085dc9e6eccdd4cdaae1a882546b3abc37c17dedddac8024d7/68747470733a2f2f636f6465636f762e696f2f67682f617363657469632d736f66742f526f77636173742f67726170682f62616467652e7376673f746f6b656e3d36475a434145584d3646)](https://codecov.io/gh/ascetic-soft/Rowcast)[![PHPStan Level 9](https://camo.githubusercontent.com/9c47b48f30dd28687f283075af582f1981f0a86034becf261e60aa08b8a0bd96/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7068707374616e2d6c6576656c253230392d627269676874677265656e)](https://phpstan.org/)[![Latest Stable Version](https://camo.githubusercontent.com/44447dffbfe7838c17ce3949c6c61e985c6616dab8150a4d16453ae696141e44/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f617363657469632d736f66742f726f7763617374)](https://packagist.org/packages/ascetic-soft/rowcast)[![Total Downloads](https://camo.githubusercontent.com/96930d5144904b08cb956a850dfec2f5a44e3113eace57ba9c97569383e09d2c/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f617363657469632d736f66742f726f7763617374)](https://packagist.org/packages/ascetic-soft/rowcast)[![PHP Version](https://camo.githubusercontent.com/9cfc68f1a6b51eed85b0201138db9b66502255fb12394afc576bde999bfefb73/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f646570656e64656e63792d762f617363657469632d736f66742f726f77636173742f706870)](https://packagist.org/packages/ascetic-soft/rowcast)[![License](https://camo.githubusercontent.com/1696da79daae3c096a2cf45932255f51ec0293b3c50ac6eab68ebda4bcec82b7/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f6c2f617363657469632d736f66742f726f7763617374)](https://packagist.org/packages/ascetic-soft/rowcast)

Lightweight DataMapper over PDO for PHP 8.4+.

Rowcast maps database rows to DTOs and back using reflection, supports explicit/auto mapping, type conversion, and includes a fluent query builder with dialect-aware UPSERT.

**Documentation:** [English](https://ascetic-soft.github.io/Rowcast/) | [Русский](https://ascetic-soft.github.io/Rowcast/ru/)

Requirements
------------

[](#requirements)

- PHP &gt;= 8.4
- `ext-pdo`

Installation
------------

[](#installation)

```
composer require ascetic-soft/rowcast
```

Quick Start
-----------

[](#quick-start)

```
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);

$user = new UserDto();
$user->email = 'alice@example.com';
$user->isActive = true;

$mapper->insert('users', $user);
$found = $mapper->findOne(UserDto::class, ['email' => $user->email]);
```

Core Concepts
-------------

[](#core-concepts)

Rowcast supports two mapping styles:

- **Auto mapping** — pass `class-string` for reads and table name for writes. Names are converted via `NameConverterInterface` (default: `SnakeCaseToCamelCase`).
- **Explicit mapping** — pass `Mapping` to control table name, column/property pairs, and ignored properties.

### Auto Mapping

[](#auto-mapping)

Table name is derived from DTO class name for reads:

ClassTable`User``users``UserProfile``user_profiles`Column/property conversion (default):

ColumnProperty`created_at``createdAt``is_active``isActive`### Explicit Mapping

[](#explicit-mapping)

```
use AsceticSoft\Rowcast\Mapping;

$mapping = Mapping::auto(UserDto::class, 'custom_users')
    ->column('usr_email', 'email')
    ->ignore('internalNote');

$user = $mapper->findOne($mapping, ['id' => 1]);
```

Use `Mapping::explicit(...)` when only declared columns must be used:

```
$mapping = Mapping::explicit(UserDto::class, 'custom_users')
    ->column('id', 'id')
    ->column('usr_email', 'email');
```

Connection
----------

[](#connection)

`Connection` wraps PDO and provides query helpers, transaction API, nested transaction support (savepoints), and query-builder factory.

### Create Connection

[](#create-connection)

```
use AsceticSoft\Rowcast\Connection;

// From DSN
$connection = Connection::create(
    dsn: 'mysql:host=localhost;dbname=app',
    username: 'root',
    password: 'secret',
    nestTransactions: true,
);

// From existing PDO
$pdo = new \PDO('sqlite::memory:');
$connection = new Connection($pdo, nestTransactions: true);
```

### Raw Queries

[](#raw-queries)

```
$stmt = $connection->executeQuery('SELECT * FROM users WHERE id = ?', [1]);
$affected = $connection->executeStatement('UPDATE users SET email = ? WHERE id = ?', ['a@x.com', 1]);
$rows = $connection->fetchAllAssociative('SELECT * FROM users');
$row = $connection->fetchAssociative('SELECT * FROM users WHERE id = ?', [1]);
$count = $connection->fetchOne('SELECT COUNT(*) FROM users');
```

### Transactions

[](#transactions)

```
$connection->transactional(function (Connection $conn) {
    $conn->executeStatement('INSERT INTO users (email) VALUES (?)', ['alice@example.com']);
});
```

Nested mode creates savepoints for inner transactions.

DataMapper API
--------------

[](#datamapper-api)

Main methods:

- `insert(string|Mapping $target, object $dto): void`
- `batchInsert(string|Mapping $target, array $dtos, ?int $maxBindParameters = null): void`
- `update(string|Mapping $target, object $dto, array $where): int`
- `batchUpdate(string|Mapping $target, array $dtos, array $identityProperties, ?int $maxBindParameters = null): void`
- `delete(string|Mapping $target, array $where): int`
- `findAll(string|Mapping $target, array $where = [], array $orderBy = [], ?int $limit = null, ?int $offset = null): array`
- `iterateAll(string|Mapping $target, array $where = [], array $orderBy = [], ?int $limit = null, ?int $offset = null): iterable`
- `findOne(string|Mapping $target, array $where = []): ?object`
- `save(string|Mapping $target, object $dto, string ...$identityProperties): void`
- `upsert(string|Mapping $target, object $dto, string ...$conflictProperties): int`
- `batchUpsert(string|Mapping $target, array $dtos, array $conflictProperties, ?int $maxBindParameters = null): void`
- `hydrate(...)`, `hydrateAll(...)`, `extract(...)`

### CRUD Example

[](#crud-example)

```
$dto = new UserDto();
$dto->email = 'alice@example.com';
$dto->isActive = true;

$mapper->insert('users', $dto);
$one = $mapper->findOne(UserDto::class, ['email' => $dto->email]);

$one->isActive = false;
$mapper->update('users', $one, ['id' => $one->id]);
$mapper->delete('users', ['id' => $one->id]);
```

### `save(...)` Example

[](#save-example)

`save(...)` checks row existence by identity columns, then performs insert or update.

```
$mapper->save('users', $dto, 'id');
```

### `upsert(...)` Example

[](#upsert-example)

```
$affected = $mapper->upsert('users', $dto, 'email');
```

### Batch operations

[](#batch-operations)

```
$mapper->batchInsert('users', [$dto1, $dto2, $dto3]);
$mapper->batchUpsert('users', [$dto1, $dto2, $dto3], ['id']);
$mapper->batchUpdate('users', [$dto1, $dto2, $dto3], ['id']);

// Optional override for chunk sizing by bind parameter limit
$mapper->batchInsert('users', [$dto1, $dto2, $dto3], 500);
$mapper->batchUpsert('users', [$dto1, $dto2, $dto3], ['id'], 500);
```

`batchInsert` and `batchUpsert` automatically split large input into chunks based on DB parameter limits (for example, SQLite: 999 bind params), while executing all chunks inside one transaction.

### Advanced `where` in DataMapper

[](#advanced-where-in-datamapper)

`DataMapper` passes `where` arrays to the same QueryBuilder condition engine, so advanced operators are available there as well:

```
$users = $mapper->findAll(UserDto::class, where: [
    'deleted_at' => null,
    '$or' => [
        ['status' => ['active', 'pending']],
        ['role' => 'admin'],
    ],
    'age >=' => 18,
]);
```

Type Conversion
---------------

[](#type-conversion)

Rowcast converts DB values to declared PHP property types on hydrate, and PHP values to DB-safe values on extract/write.

Built-in converters:

- `ScalarConverter` (`int`, `float`, `string`)
- `BoolConverter` (`bool` &lt;-&gt; `0/1`)
- `DateTimeConverter` (`DateTimeInterface` &lt;-&gt; formatted UTC string)
- `JsonConverter` (`array` &lt;-&gt; JSON)
- `EnumConverter` (`BackedEnum` &lt;-&gt; backing value)

### Custom Type Converter

[](#custom-type-converter)

Implement `TypeConverterInterface` and pass a custom registry to `DataMapper`:

```
use AsceticSoft\Rowcast\DataMapper;
use AsceticSoft\Rowcast\TypeConverter\TypeConverterInterface;
use AsceticSoft\Rowcast\TypeConverter\TypeConverterRegistry;

final class UuidConverter implements TypeConverterInterface
{
    public function supports(string $phpType): bool
    {
        return $phpType === Uuid::class;
    }

    public function toPhp(mixed $value, string $phpType): mixed
    {
        return new Uuid((string) $value);
    }

    public function toDb(mixed $value): mixed
    {
        return (string) $value;
    }
}

$converters = TypeConverterRegistry::defaults()->add(new UuidConverter());
$mapper = new DataMapper($connection, typeConverter: $converters);
```

Custom Name Converter
---------------------

[](#custom-name-converter)

Implement `NameConverterInterface` and pass it to `DataMapper`:

```
use AsceticSoft\Rowcast\DataMapper;
use AsceticSoft\Rowcast\NameConverter\NameConverterInterface;

final class PrefixedConverter implements NameConverterInterface
{
    public function toPropertyName(string $columnName): string
    {
        return lcfirst(str_replace('usr_', '', $columnName));
    }

    public function toColumnName(string $propertyName): string
    {
        return 'usr_' . $propertyName;
    }
}

$mapper = new DataMapper($connection, nameConverter: new PrefixedConverter());
```

Query Builder
-------------

[](#query-builder)

`Connection::createQueryBuilder()` provides a fluent SQL builder.

### SELECT

[](#select)

```
$rows = $connection->createQueryBuilder()
    ->select('u.id', 'u.email')
    ->from('users', 'u')
    ->where('u.is_active = :active')
    ->orderBy('u.id', 'DESC')
    ->setOffset(20)
    ->setLimit(10)
    ->setParameter('active', 1)
    ->fetchAllAssociative();
```

For pagination, use:

- `setOffset(int $offset)` — start row
- `setLimit(int $limit)` — max rows

You can also pass associative arrays to `where()`, `andWhere()`, and `orWhere()`:

```
$rows = $connection->createQueryBuilder()
    ->select('*')
    ->from('users')
    ->where(['email' => 'alice@example.com', 'is_active' => 1])
    ->fetchAllAssociative();
// SQL: SELECT * FROM users WHERE email = :w_email AND is_active = :w_is_active
```

`array` predicates are converted to `field = :param` expressions joined by `AND`:

- `where(['a' => 1, 'b' => 2])` -&gt; `a = :w_a AND b = :w_b`
- `andWhere(['a' => 1])` appends another `AND` block
- `orWhere(['a' => 1])` wraps previous predicate: `(prev OR a = :w_a)`

Parameter names are generated automatically and made unique (`:w_id`, `:w_id_1`, ...).

Supported array operators:

```
// IS NULL / IS NOT NULL
->where(['deleted_at' => null])        // deleted_at IS NULL
->where(['deleted_at !=' => null])     // deleted_at IS NOT NULL

// IN / NOT IN
->where(['status' => ['active', 'pending']])     // status IN (...)
->where(['status !=' => ['banned']])             // status NOT IN (...)
->where(['status IN' => ['active']])             // explicit IN
->where(['status NOT IN' => ['banned']])         // explicit NOT IN

// BackedEnum in WHERE (direct QueryBuilder usage)
->where(['status' => UserStatus::Active])                               // status = :w_status, parameter value: 'active'
->where(['status' => [UserStatus::Active, UserStatus::Inactive]])       // status IN (...), parameters: 'active', 'inactive'

// Comparison operators
->where(['age >' => 18, 'age =' => 18]``age >= :w_age``LIKE``['name LIKE' => 'A%']``name LIKE :w_name``ILIKE``['name ILIKE' => 'a%']``name ILIKE :w_name``NOT LIKE``['name NOT LIKE' => '%bot%']``name NOT LIKE :w_name``NOT ILIKE``['name NOT ILIKE' => '%bot%']``name NOT ILIKE :w_name``BETWEEN``['age BETWEEN' => [18, 65]]``age BETWEEN :w_age AND :w_age_1`Notes:

- Empty `IN` array compiles to `1 = 0` (always false).
- Empty `NOT IN` array compiles to `1 = 1` (always true).
- `ILIKE` and `NOT ILIKE` are PostgreSQL-specific.
- `BackedEnum` values are normalized to backing scalar values in `WHERE` parameters (including `IN` / `NOT IN` arrays).

Dialect-specific operator support:

DialectExtra operators over base setPostgreSQL (`pgsql`)`ILIKE`, `NOT ILIKE`MySQL (`mysql`)noneSQLite (`sqlite`)noneGeneric/other driversnoneBase set for all dialects: `>`, `>=`, `createQueryBuilder()
    ->select('*')
    ->from('users')
    ->whereOr(
        ['status' => 'active', 'age >' => 18],
        ['role' => 'admin'],
    )
    ->fetchAllAssociative();
```

Combine with existing filters:

```
// deleted_at IS NULL AND ((status = 'active') OR (role = 'admin'))
$rows = $connection->createQueryBuilder()
    ->select('*')
    ->from('users')
    ->where(['deleted_at' => null])
    ->andWhereOr(['status' => 'active'], ['role' => 'admin'])
    ->fetchAllAssociative();
```

Nested-key style in one array:

```
$rows = $connection->createQueryBuilder()
    ->select('*')
    ->from('users')
    ->where([
        'age >' => 18,
        '$or' => [
            ['status' => 'active'],
            ['$and' => [
                ['role' => 'admin'],
                ['verified' => true],
            ]],
        ],
    ])
    ->fetchAllAssociative();
```

OR composition reference:

PatternExampleSQL fragment (shape)`whereOr(...groups)``->whereOr(['status' => 'active'], ['role' => 'admin'])``((status = :w_status) OR (role = :w_role))``andWhereOr(...groups)``->where(['deleted_at' => null])->andWhereOr(['status' => 'active'], ['role' => 'admin'])``deleted_at IS NULL AND ((status = :w_status) OR (role = :w_role))``$or` inside `where([...])``->where(['age >' => 18, '$or' => [['status' => 'active'], ['role' => 'admin']]])``age > :w_age AND ((status = :w_status) OR (role = :w_role))``$and` inside `$or``->where(['$or' => [['status' => 'active'], ['$and' => [['role' => 'admin'], ['verified' => true]]]]])``((status = :w_status) OR ((role = :w_role) AND (verified = :w_verified)))`Mixed operators in OR groups`->whereOr(['status' => ['active', 'pending'], 'deleted_at' => null], ['name LIKE' => 'A%', 'age BETWEEN' => [18, 65]])``((status IN (...) AND deleted_at IS NULL) OR (name LIKE :w_name AND age BETWEEN :w_age AND :w_age_1))`### INSERT / UPDATE / DELETE

[](#insert--update--delete)

```
$connection->createQueryBuilder()
    ->insert('users')
    ->values(['email' => ':email', 'is_active' => ':is_active'])
    ->setParameter('email', 'alice@example.com')
    ->setParameter('is_active', 1)
    ->executeStatement();

$connection->createQueryBuilder()
    ->update('users')
    ->values(['is_active' => ':is_active'])
    ->where('id = :id')
    ->setParameter('is_active', 0)
    ->setParameter('id', 1)
    ->executeStatement();

$connection->createQueryBuilder()
    ->delete('users')
    ->where('id = :id')
    ->setParameter('id', 1)
    ->executeStatement();
```

### UPSERT

[](#upsert)

```
$sql = $connection->createQueryBuilder()
    ->upsert('users')
    ->values(['email' => ':email', 'name' => ':name'])
    ->onConflict('email')
    ->doUpdateSet(['name'])
    ->getSQL();
```

`Upsert` is compiled via SQL dialects:

- `MysqlDialect`
- `PostgresDialect`
- `SqliteDialect`
- `GenericDialect` (throws for unsupported UPSERT)

`WHERE` array operator support is also dialect-aware (for example, `ILIKE`/`NOT ILIKE` only for PostgreSQL).

Architecture
------------

[](#architecture)

```
AsceticSoft\Rowcast\
├── ConnectionInterface
├── Connection
├── DataMapper
├── Hydrator
├── Extractor
├── Mapping
├── TargetResolver
├── QueryHelper
├── NameConverter\
│   ├── NameConverterInterface
│   └── SnakeCaseToCamelCase
├── TypeConverter\
│   ├── TypeConverterInterface
│   ├── TypeConverterRegistry
│   ├── ScalarConverter
│   ├── BoolConverter
│   ├── DateTimeConverter
│   ├── JsonConverter
│   └── EnumConverter
└── QueryBuilder\
    ├── QueryBuilder
    ├── QueryType
    ├── Dialect\
    │   ├── DialectInterface
    │   ├── DialectFactory
    │   ├── AbstractStandardDialect
    │   ├── AbstractOnConflictDialect
    │   ├── MysqlDialect
    │   ├── PostgresDialect
    │   ├── SqliteDialect
    │   └── GenericDialect
    └── Compiler\
        ├── SqlCompilerInterface
        ├── SqlFragments
        ├── SelectCompiler
        ├── InsertCompiler
        ├── UpsertCompiler
        ├── UpdateCompiler
        └── DeleteCompiler

```

Development
-----------

[](#development)

```
composer install
make install-hooks
vendor/bin/phpunit
vendor/bin/phpstan analyse
```

License
-------

[](#license)

MIT

###  Health Score

44

—

FairBetter than 92% of packages

Maintenance86

Actively maintained with recent releases

Popularity11

Limited adoption so far

Community8

Small or concentrated contributor base

Maturity60

Established project with proven stability

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~2 days

Total

17

Last Release

69d ago

Major Versions

v1.1.3 → v2.0.02026-03-07

### Community

Maintainers

![](https://www.gravatar.com/avatar/67126642d0e8ae8837b3104dcd120f7992ed0837538a7d140914e17420eb17c2?d=identicon)[borodulin](/maintainers/borodulin)

---

Top Contributors

[![borodulin](https://avatars.githubusercontent.com/u/8121448?v=4)](https://github.com/borodulin "borodulin (43 commits)")

---

Tags

phpdatabaseormpdomappingquery builderdtohydrationdatamappertype-casting

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Code StylePHP CS Fixer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/ascetic-soft-rowcast/health.svg)

```
[![Health](https://phpackages.com/badges/ascetic-soft-rowcast/health.svg)](https://phpackages.com/packages/ascetic-soft-rowcast)
```

###  Alternatives

[bauer01/unimapper

Universal mapping tool for collecting data from different sources

102.6k6](/packages/bauer01-unimapper)[riverside/php-orm

PHP ORM micro-library and query builder

111.2k](/packages/riverside-php-orm)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
