Most PHP developers know ORM usage through the traditional request lifecycle:
- PHP starts;
- a request is processed;
- the ORM creates its objects;
- the response is sent;
- 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
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
Factories sit above those components:
ConnectionFactory
│
▼
EntityManagerFactory
│
▼
Application managers
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;
}
}
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
{
}
You can then retrieve the manager through the factory:
$userManager = $entityManagerFactory->get(UserManager::class);
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();
A loaded entity can also be changed and persisted:
$user = $userManager->findOneBy([
'id' => 42,
]);
$user
->setUsername('Alice')
->persist();
And deleted:
$user->delete();
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');
The alias owner can then be used normally:
$query
->where()
->firstCondition(
$query->getFieldForCondition('username', 'owner'),
ConditionOperatorType::equal,
':username',
);
$query->setParameter('username', 'alice');
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);
A deliberate design choice here is to resolve entity fields through ORM metadata:
$query->getFieldForCondition('username', 'user');
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
and:
construct direct database update
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',
);
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);
}
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/
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.*
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();
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'];
}
Output aliases are supported:
$query->select(
'user.username AS displayName'
);
And you can combine several projections:
$query
->select('user.id')
->addSelect('user.createdAt as registeredAt');
When exactly one row is expected:
$result = $query->getResult();
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();
Results remain scalar:
$count = $result['invoice']['invoiceCount'];
$total = $result['invoice']['totalAmount'];
$average = $result['invoice']['averageAmount'];
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');
Grouped queries are also possible.
For example:
$rows = $orderManager
->createStatsQueryBuilder('order')
->select('order.status')
->count('*', 'orders')
->sum('order.total', 'amount')
->getResults();
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();
There is also a compact expression syntax:
$result = $invoiceManager
->createStatsQueryBuilder('invoice')
->stringOperation(
'(sum(invoice.total) - sum(invoice.discount)) / count(*)',
'averageNet',
)
->getResult();
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',
';',
);
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
The new Core-3-compatible bundle line targets:
Core 3.0.*
Symfony 7.4
Symfony 8
PHP 8.3–8.4
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
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
An in-memory connection can simply be:
small_swoole_entity_manager:
connections:
memory:
type: swoole-db
The Symfony configuration layer validates connection configuration before passing normalized values to Core.
For example:
max_connections: 50
becomes Core's:
'maxConnections' => 50
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;
}
}
The connection factory is injected the same way:
use Small\SwooleEntityManagerBundle\Contract\ConnectionFactoryInterface;
final class ReportGateway
{
public function __construct(
private readonly ConnectionFactoryInterface $connectionFactory,
) {
}
}
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
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
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();
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
Layer groups are configured by selector:
small_swoole_entity_manager:
database_layers:
app: "@projectRoot/databaseLayers"
users: "@UserBundle/Resources/databaseLayers"
You can execute only one selector:
bin/console \
swoole:entity-manager:layers:execute \
--selector=users
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
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
and:
an ORM designed for a persistent Swoole/OpenSwoole runtime
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
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
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
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
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
- Repository: git.small-project.dev/lib/small-swoole-entity-manager
- Packagist: small/swoole-entity-manager-core
- Documentation: swoole-entity-manager.small-project.dev
Top comments (0)