DEV Community

Cover image for Small Swoole Entity Manager 3: a coroutine-ready PHP ORM with Symfony 7.4 and 8 support
sebk69
sebk69

Posted on

Small Swoole Entity Manager 3: a coroutine-ready PHP ORM with Symfony 7.4 and 8 support

Most PHP developers know ORM usage through the traditional request lifecycle:

  1. PHP starts;
  2. a request is processed;
  3. the ORM creates its objects;
  4. the response is sent;
  5. the process ends or its request state is discarded.

That model works extremely well with PHP-FPM.

But Swoole and OpenSwoole applications have a different lifecycle.

The same PHP process can stay alive for hours, handle many requests or jobs, keep connection pools in memory and execute multiple coroutines concurrently.

That changes some architectural assumptions.

This is the environment Small Swoole Entity Manager was designed for.

Today I want to introduce the project, because relatively few PHP developers know it, show some of its original features, explain a few architectural choices, and present what has changed with Core 3 — including significantly improved Symfony compatibility.

What is Small Swoole Entity Manager?

Small Swoole Entity Manager is a PHP ORM designed for applications running with:

  • Swoole;
  • OpenSwoole;
  • long-running PHP workers;
  • coroutine-based database access;
  • persistent in-memory services.

The core package is framework-independent:

composer require small/swoole-entity-manager-core:^3.0
Enter fullscreen mode Exit fullscreen mode

Core 3 requires PHP 8.3 or newer.

The project currently supports:

  • MySQL;
  • PostgreSQL;
  • Small Swoole DB, an in-memory relational backend based on Swoole tables.

The important point is that the ORM is not simply a conventional ORM executed inside Swoole.

Its factories, connection management and persistence model were designed around a persistent runtime.

The basic architecture

The project deliberately separates several responsibilities.

At a high level:

Entity
  │
  ▼
Entity Manager
  │
  ├── Query Builders
  │
  ├── Persistence
  │
  └── Relations
  │
  ▼
Connection
  │
  ▼
Connection Pool / Driver
Enter fullscreen mode Exit fullscreen mode

Factories sit above those components:

ConnectionFactory
      │
      ▼
EntityManagerFactory
      │
      ▼
Application managers
Enter fullscreen mode Exit fullscreen mode

This separation is particularly useful in long-running processes.

Connections can remain pooled while entity manager instances can be reset or recreated at logical request boundaries.

That distinction becomes especially important when integrating the ORM with Symfony, which we will come back to later.

Defining an entity

Entities use PHP attributes.

A small entity can look like this:

<?php

declare(strict_types=1);

namespace App\Entity;

use Small\SwooleEntityManager\Entity\AbstractEntity;
use Small\SwooleEntityManager\Entity\Attribute\Field;
use Small\SwooleEntityManager\Entity\Attribute\OrmEntity;
use Small\SwooleEntityManager\Entity\Attribute\PrimaryKey;
use Small\SwooleEntityManager\Entity\Enum\FieldValueType;

#[OrmEntity]
final class User extends AbstractEntity
{
    #[PrimaryKey]
    private ?int $id = null;

    #[Field(type: FieldValueType::string)]
    private ?string $username = null;

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getUsername(): ?string
    {
        return $this->username;
    }

    public function setUsername(string $username): self
    {
        $this->username = $username;

        return $this;
    }
}
Enter fullscreen mode Exit fullscreen mode

An entity manager connects this PHP model to a relational table:

<?php

declare(strict_types=1);

namespace App\EntityManager;

use App\Entity\User;
use Small\SwooleEntityManager\EntityManager\AbstractRelationnalManager;
use Small\SwooleEntityManager\EntityManager\Attribute\Connection;
use Small\SwooleEntityManager\EntityManager\Attribute\Entity;

#[Connection(
    dbTableName: 'users',
    connectionName: 'default'
)]
#[Entity(User::class)]
final class UserManager extends AbstractRelationnalManager
{
}
Enter fullscreen mode Exit fullscreen mode

You can then retrieve the manager through the factory:

$userManager = $entityManagerFactory->get(UserManager::class);
Enter fullscreen mode Exit fullscreen mode

Persistence stays close to the entity

One of the original design choices of the ORM is that an entity can persist itself once it is associated with its manager.

For example:

$user = $userManager->newEntity();

$user
    ->setUsername('alice')
    ->persist();
Enter fullscreen mode Exit fullscreen mode

A loaded entity can also be changed and persisted:

$user = $userManager->findOneBy([
    'id' => 42,
]);

$user
    ->setUsername('Alice')
    ->persist();
Enter fullscreen mode Exit fullscreen mode

And deleted:

$user->delete();
Enter fullscreen mode Exit fullscreen mode

The manager still owns the persistence infrastructure.

The entity API is a convenient façade over that infrastructure rather than an independent database abstraction.

Relations and entity graphs

The ORM also supports relations between entities.

For example, a user can expose a collection of projects, or another entity can expose a to-one relation.

Those relations can then be joined from a relational query builder.

The idea is to define the relationship once in metadata and reuse it when constructing queries instead of repeatedly writing raw join conditions.

For example:

$query = $projectManager
    ->createQueryBuilder('project')
    ->innerJoin('project', 'user', 'owner');
Enter fullscreen mode Exit fullscreen mode

The alias owner can then be used normally:

$query
    ->where()
    ->firstCondition(
        $query->getFieldForCondition('username', 'owner'),
        ConditionOperatorType::equal,
        ':username',
    );

$query->setParameter('username', 'alice');
Enter fullscreen mode Exit fullscreen mode

The relational query builder

The original relational builder is used when you want hydrated entities as the result.

For example:

$query = $userManager->createQueryBuilder('user');

$query
    ->where()
    ->firstCondition(
        $query->getFieldForCondition('enabled', 'user'),
        ConditionOperatorType::equal,
        ':enabled',
    );

$query
    ->setParameter('enabled', true)
    ->addOrderBy(
        'username',
        'user',
        OrderByDirectionType::asc,
    )
    ->paginate(
        page: 1,
        pageSize: 25,
    );

$users = $userManager->getResult($query);
Enter fullscreen mode Exit fullscreen mode

A deliberate design choice here is to resolve entity fields through ORM metadata:

$query->getFieldForCondition('username', 'user');
Enter fullscreen mode Exit fullscreen mode

instead of asking application code to manually concatenate SQL column names.

The query builder therefore remains aware of:

  • entity field names;
  • database field names;
  • relation aliases;
  • configured managers.

Update and delete builders

Entity hydration is not always necessary.

For bulk operations, the ORM also exposes update and delete builders.

That allows applications to choose between:

load entity → run lifecycle → modify → persist
Enter fullscreen mode Exit fullscreen mode

and:

construct direct database update
Enter fullscreen mode Exit fullscreen mode

depending on the semantics required by the operation.

This distinction matters when dealing with large datasets or background jobs.

Lifecycle hooks

Entities can participate in persistence lifecycle events.

For example, applications can react:

  • before persistence;
  • after persistence;
  • before updates;
  • after updates;
  • before deletion;
  • after deletion.

Bulk operations can optionally bypass these hooks when the caller explicitly wants direct database semantics.

Again, the goal is to make the trade-off explicit instead of hiding it.

Persistence threads

Swoole gives PHP applications the ability to execute independent tasks concurrently.

Small Swoole Entity Manager includes persistence mechanisms intended to work with that model.

Instead of assuming every persistence graph must be written synchronously from top to bottom, independent operations can be coordinated as concurrent work.

That becomes useful for large entity graphs where unrelated branches do not need to wait for one another.

This is one of the areas where designing specifically for Swoole is different from merely taking a traditional ORM and running it inside a Swoole worker.

Connection pools are first-class

A long-running worker should not reconnect to the database from scratch for every small operation.

Connections are configured through a factory.

For example:

$connectionFactory = new ConnectionFactory(
    config: [
        'default' => [
            'type' => 'mysql',
            'host' => 'mysql',
            'port' => '3306',
            'database' => 'app',
            'user' => 'app',
            'password' => 'secret',
            'encoding' => 'utf8mb4',
            'maxConnections' => 50,
        ],
    ],
    defaultConnection: 'default',
);
Enter fullscreen mode Exit fullscreen mode

MySQL and PostgreSQL connections use pooling infrastructure appropriate for persistent runtimes.

For MySQL with recent Swoole versions, PDO is used together with coroutine hooks.

For example:

if (class_exists(\Swoole\Runtime::class)) {
    \Swoole\Runtime::enableCoroutine(SWOOLE_HOOK_ALL);
}
Enter fullscreen mode Exit fullscreen mode

The ORM also makes a distinction between the pool and a connection checked out from that pool.

A database connection being used by one coroutine should not simply become shared mutable state between unrelated coroutines.

Database layers

Small Swoole Entity Manager also has its own schema evolution mechanism called database layers.

A project keeps ordered database changes in layer directories.

Those layers can then be executed against a configured connection.

The concept is intentionally simple:

databaseLayers/
├── 001-initial-schema/
├── 002-add-user-status/
└── 003-add-project-index/
Enter fullscreen mode Exit fullscreen mode

The Symfony bundle exposes this workflow through a console command, which makes layers convenient in deployment pipelines as well.

What changed in Core 3?

Core 3 contains dependency modernization, internal performance work and several new query capabilities.

The dependency baseline now uses:

PHP >= 8.3

small/collection        4.0.*
small/swoole-db         2.0.*
small/swoole-patterns   26.0.*
Enter fullscreen mode Exit fullscreen mode

The project also moved away from coupling the persistence core to small/forms.

AbstractManager::getForm() was removed.

That is an intentional architectural change.

Validation belongs to the application or framework integration layer rather than being a mandatory dependency of the ORM core.

For example, with Symfony, using Symfony Validator or Symfony Forms is now a natural choice.

Scalar queries in Core 3

Until now, I have shown queries returning entities.

But many real SQL queries do not need full entity hydration.

Core 3 adds a scalar query builder:

$query = $userManager
    ->createScallarQueryBuilder('user')
    ->select('user.id, user.username as name');

$rows = $query->getResults();
Enter fullscreen mode Exit fullscreen mode

Yes, the public API currently spells Scallar with two l characters.

That spelling is kept for API compatibility.

Each result contains fields grouped by entity alias:

foreach ($rows as $row) {
    $id = $row['user']['id'];
    $name = $row['user']['name'];
}
Enter fullscreen mode Exit fullscreen mode

Output aliases are supported:

$query->select(
    'user.username AS displayName'
);
Enter fullscreen mode Exit fullscreen mode

And you can combine several projections:

$query
    ->select('user.id')
    ->addSelect('user.createdAt as registeredAt');
Enter fullscreen mode Exit fullscreen mode

When exactly one row is expected:

$result = $query->getResult();
Enter fullscreen mode Exit fullscreen mode

The method throws when there are zero or multiple rows.

Statistics queries

Core 3 also introduces StatsQueryBuilder.

For example:

$result = $invoiceManager
    ->createStatsQueryBuilder('invoice')
    ->count('*', 'invoiceCount')
    ->sum('invoice.total', 'totalAmount')
    ->avg('invoice.total', 'averageAmount')
    ->getResult();
Enter fullscreen mode Exit fullscreen mode

Results remain scalar:

$count = $result['invoice']['invoiceCount'];
$total = $result['invoice']['totalAmount'];
$average = $result['invoice']['averageAmount'];
Enter fullscreen mode Exit fullscreen mode

Available operations include:

$query
    ->count('*', 'rows')
    ->countDistinct('user.id', 'users')
    ->sum('invoice.total', 'total')
    ->avg('invoice.total', 'average')
    ->min('invoice.total', 'minimum')
    ->max('invoice.total', 'maximum')
    ->stddev('invoice.total', 'stddev')
    ->varPop('invoice.total', 'variance');
Enter fullscreen mode Exit fullscreen mode

Grouped queries are also possible.

For example:

$rows = $orderManager
    ->createStatsQueryBuilder('order')
    ->select('order.status')
    ->count('*', 'orders')
    ->sum('order.total', 'amount')
    ->getResults();
Enter fullscreen mode Exit fullscreen mode

The regular selected field becomes part of the generated grouping.

Aggregate arithmetic

Sometimes a statistic is an expression rather than a single function.

Core 3 supports programmatically composed aggregate arithmetic.

For example:

$average = (new OperationCollection())
    ->first(
        StatsOperationType::sum,
        'invoice.total',
    )
    ->div(
        StatsOperationType::count,
        '*',
    );

$result = $invoiceManager
    ->createStatsQueryBuilder('invoice')
    ->operation(
        $average,
        'averageTotal',
    )
    ->getResult();
Enter fullscreen mode Exit fullscreen mode

There is also a compact expression syntax:

$result = $invoiceManager
    ->createStatsQueryBuilder('invoice')
    ->stringOperation(
        '(sum(invoice.total) - sum(invoice.discount)) / count(*)',
        'averageNet',
    )
    ->getResult();
Enter fullscreen mode Exit fullscreen mode

This is not raw SQL injection into the SELECT clause.

The expression is tokenized and parsed into an internal expression structure.

Only supported operations and aggregate functions are accepted.

For example:

  • arbitrary SQL is rejected;
  • fields must be inside aggregate functions;
  • nested aggregates are rejected;
  • aliases and fields are resolved through ORM metadata.

This was an important architectural choice.

The convenience of a compact expression syntax should not require giving up query validation.

MySQL and PostgreSQL

Statistics queries currently target SQL backends.

Both MySQL and PostgreSQL adapters render the statistics AST into the appropriate database syntax.

For example, string aggregation differs between databases.

The API can expose:

$query->groupConcat(
    'user.username',
    'names',
    ';',
);
Enter fullscreen mode Exit fullscreen mode

while the adapters can translate that appropriately for each SQL backend.

Small Swoole DB deliberately rejects StatsQueryBuilder.

That is preferable to pretending an in-memory relational backend has SQL aggregate semantics that it cannot implement correctly.

Symfony is now a first-class integration target

This is one of the biggest points I want to highlight.

Small Swoole Entity Manager has a Symfony integration package:

composer require small/swoole-entity-manager-bundle
Enter fullscreen mode Exit fullscreen mode

The new Core-3-compatible bundle line targets:

Core 3.0.*
Symfony 7.4
Symfony 8
PHP 8.3–8.4
Enter fullscreen mode Exit fullscreen mode

At the time of writing, this work is on the bundle's 2.x development line.

The important architectural improvement is that the Symfony bundle now behaves much more like a modern reusable Symfony bundle.

Symfony configuration

A database connection can be configured with Symfony YAML:

small_swoole_entity_manager:
  default_connection: default

  connections:
    default:
      type: mysql
      host: mysql
      port: 3306
      database: app
      user: app
      password: secret
      encoding: utf8mb4
      max_connections: 50
Enter fullscreen mode Exit fullscreen mode

PostgreSQL is similar:

small_swoole_entity_manager:
  connections:
    analytics:
      type: postgres
      host: postgres
      port: 5432
      database: analytics
      user: app
      password: secret
      encoding: UTF8
      max_connections: 25
Enter fullscreen mode Exit fullscreen mode

An in-memory connection can simply be:

small_swoole_entity_manager:
  connections:
    memory:
      type: swoole-db
Enter fullscreen mode Exit fullscreen mode

The Symfony configuration layer validates connection configuration before passing normalized values to Core.

For example:

max_connections: 50
Enter fullscreen mode Exit fullscreen mode

becomes Core's:

'maxConnections' => 50
Enter fullscreen mode Exit fullscreen mode

This keeps Symfony-facing configuration idiomatic without forcing the framework-independent Core package to adopt Symfony conventions.

Constructor injection instead of container lookups

Applications should depend on contracts.

For example:

use Small\SwooleEntityManagerBundle\Contract\EntityManagerFactoryInterface;

final class UserService
{
    public function __construct(
        private readonly EntityManagerFactoryInterface $entityManagerFactory,
    ) {
    }

    public function find(int $id): User
    {
        /** @var UserManager $manager */
        $manager = $this->entityManagerFactory->get(
            UserManager::class
        );

        /** @var User $user */
        $user = $manager->findOneBy([
            'id' => $id,
        ]);

        return $user;
    }
}
Enter fullscreen mode Exit fullscreen mode

The connection factory is injected the same way:

use Small\SwooleEntityManagerBundle\Contract\ConnectionFactoryInterface;

final class ReportGateway
{
    public function __construct(
        private readonly ConnectionFactoryInterface $connectionFactory,
    ) {
    }
}
Enter fullscreen mode Exit fullscreen mode

The concrete bundle implementations remain internal implementation details.

That is important for reusable Symfony bundles: application code should normally depend on a stable service contract rather than on the bundle's internal class structure.

Why kernel.reset matters with Swoole

This is probably the most important Symfony/Swoole integration detail.

The Core EntityManagerFactory caches manager instances.

In a classic PHP-FPM request, the process lifecycle naturally limits how long that state exists.

In a persistent application server, that is no longer true.

A Symfony service can remain alive across many logical requests.

So the Symfony bundle registers its entity manager factory with Symfony's reset mechanism.

Conceptually:

Request A
  │
  ├── manager cache created
  │
  ▼
Symfony kernel.reset
  │
  └── manager cache cleared
  │
Request B
Enter fullscreen mode Exit fullscreen mode

The connection pools can remain alive.

What gets reset is the application-level manager cache.

That distinction is exactly what we want:

Persistent infrastructure
    connection pools
    runtime
    service container

Request-scoped mutable state
    cached manager instances
Enter fullscreen mode Exit fullscreen mode

This is a good example of why long-running PHP needs slightly different dependency lifecycle thinking.

If an application owns a custom worker loop that does not trigger Symfony's reset mechanism, it can explicitly call:

$entityManagerFactory->reset();
Enter fullscreen mode Exit fullscreen mode

at the appropriate job boundary.

Symfony database layers

The Symfony bundle also exposes database layers through the console:

bin/console swoole:entity-manager:layers:execute
Enter fullscreen mode Exit fullscreen mode

Layer groups are configured by selector:

small_swoole_entity_manager:
  database_layers:
    app: "@projectRoot/databaseLayers"
    users: "@UserBundle/Resources/databaseLayers"
Enter fullscreen mode Exit fullscreen mode

You can execute only one selector:

bin/console \
  swoole:entity-manager:layers:execute \
  --selector=users
Enter fullscreen mode Exit fullscreen mode

This is particularly useful in deployment automation.

Unknown selectors and missing layer configuration return a failure status instead of silently succeeding.

Explicit migration parameters

An older integration pattern passed the complete Symfony parameter bag to database layers.

That is convenient, but it creates too much coupling.

It also potentially exposes unrelated configuration to migration code.

The new configuration uses an explicit allow-list instead:

small_swoole_entity_manager:
  layer_parameters:
    application_environment: prod
    tenant: main
    retries: 3
Enter fullscreen mode Exit fullscreen mode

Only those values are passed to Core database layers.

This follows a broader architecture rule I strongly prefer:

Dependencies should receive the minimum context they actually require.

A migration layer does not need to know everything the Symfony container knows.

Symfony 7.4 and Symfony 8

The bundle has been tested against both Symfony 7.4 and Symfony 8 component lines.

That means a modern Symfony application does not have to choose between:

modern Symfony
Enter fullscreen mode Exit fullscreen mode

and:

an ORM designed for a persistent Swoole/OpenSwoole runtime
Enter fullscreen mode Exit fullscreen mode

The integration is designed to support both.

The target package split is:

small/swoole-entity-manager-core
    framework-independent persistence

small/swoole-entity-manager-bundle
    Symfony integration
Enter fullscreen mode Exit fullscreen mode

I think keeping that boundary is important.

Core should not know about:

  • Symfony's container;
  • Symfony reset tags;
  • Symfony configuration trees;
  • Symfony console commands.

And the bundle should not reimplement:

  • entity metadata;
  • SQL rendering;
  • persistence;
  • relation handling;
  • connection pools.

Each package has one clear responsibility.

Why not just make Core Symfony-specific?

Because Swoole and OpenSwoole are not Symfony-specific.

The same ORM can be used:

  • in a small custom Swoole HTTP server;
  • in a queue consumer;
  • in a Symfony application server;
  • in a command-line worker;
  • in another framework integration.

Core therefore stays framework-independent.

Symfony-specific lifecycle concerns are handled at the integration boundary.

For me, this is the cleaner architecture:

                 Application
                     │
          ┌──────────┴──────────┐
          │                     │
      Symfony                Custom runtime
          │                     │
 Symfony Bundle                │
          │                     │
          └──────────┬──────────┘
                     │
                     ▼
             Entity Manager Core
                     │
                     ▼
          DB drivers / connection pools
Enter fullscreen mode Exit fullscreen mode

A note about validation

Core 3 removed its dependency on small/forms.

That is also consistent with this architecture.

For a Symfony project, validation can now naturally stay in Symfony:

Request
   │
   ▼
DTO / Symfony Form
   │
   ▼
Symfony Validator
   │
   ▼
Application service
   │
   ▼
Entity Manager
Enter fullscreen mode Exit fullscreen mode

The ORM does persistence.

The framework handles HTTP input and validation.

A custom Swoole application can choose another validation library without paying for Symfony-specific dependencies.

Long-running PHP changes what "stateless" means

One lesson from building software around Swoole is that dependency lifetimes become much more visible.

In PHP-FPM, it is easy to accidentally rely on process destruction as cleanup.

With persistent workers, you need to decide deliberately which state is:

  • global;
  • pooled;
  • cached;
  • request-scoped;
  • job-scoped;
  • resettable.

Small Swoole Entity Manager's architecture tries to make those boundaries explicit.

A connection pool should survive.

A manager cache may not.

An immutable metadata cache can survive.

Request-specific mutable entities should not accidentally become global state.

Those choices matter as soon as PHP stops restarting for every HTTP request.

Where the project is today

The current Core release is:

small/swoole-entity-manager-core 3.0.0
Enter fullscreen mode Exit fullscreen mode

Core 3 introduces:

  • updated Small runtime dependencies;
  • scalar query results;
  • statistical query builders;
  • aggregate arithmetic;
  • parsed string aggregate expressions;
  • MySQL/PostgreSQL aggregate rendering;
  • internal reflection and hydration optimizations;
  • stricter testing with 100% line coverage;
  • removal of the old Core/forms coupling.

The Symfony bundle's Core-3-compatible 2.x work adds:

  • Symfony 7.4 compatibility;
  • Symfony 8 compatibility;
  • modern bundle configuration;
  • contract-based dependency injection;
  • private implementation services;
  • validated connection options;
  • port;
  • max_connections;
  • Swoole DB configuration;
  • kernel.reset;
  • selector-based database layers;
  • explicit layer parameters;
  • proper command failure codes.

Current compatibility note

One package currently remains on the previous Core generation.

small/swoole-entity-manager-strates 0.1.10 currently requires Core ~2.7.0.

Strates provides immutable snapshot persistence and atomic publication of complete business states, but it has not yet moved to the Core 3 dependency line.

I prefer to state that clearly rather than imply that every package in the ecosystem has already migrated.

Documentation

The project documentation covers:

  • Core entities and managers;
  • relations and collections;
  • relational queries;
  • scalar queries;
  • statistical queries;
  • update/delete builders;
  • lifecycle hooks;
  • database layers;
  • transactions;
  • persistence threads;
  • MySQL/PostgreSQL/Swoole DB runtime configuration;
  • Core 3 migration;
  • Symfony integration;
  • Symfony bundle v2 migration.

Final thoughts

Small Swoole Entity Manager is still a relatively little-known project.

That is one reason I wanted to write this introduction instead of publishing only a "what's new in version 3" changelog.

Before discussing aggregate query expressions or Symfony 8 compatibility, it is important to explain the problem the ORM is trying to solve.

The central idea is not:

let's build another ORM.

It is closer to:

what should an ORM look like when PHP is a persistent, concurrent application runtime instead of a process that disappears after every request?

That leads to choices around:

  • connection pooling;
  • coroutine-aware database access;
  • manager lifecycle;
  • explicit reset boundaries;
  • entity graph persistence;
  • framework-independent Core architecture;
  • thin framework integrations.

With Core 3 and the new Symfony bundle line, the project is also becoming much easier to integrate into a modern Symfony application without losing those persistent-runtime characteristics.

If you are experimenting with Symfony + Swoole, OpenSwoole, long-running workers or coroutine-based PHP services, I would be very interested in feedback on this architecture and on the cases that are still missing.

Links

Top comments (0)