Repositories in this framework work exactly as they do in any Symfony/Doctrine project. The only framework-specific addition is that AbstractEntity ships with built-in static finder methods via EntityRepositoriesTrait — no repository class required to use them. AbstractEntityRepository is a separate optional convenience that adds persist() and remove() shortcuts to your repository class.
Static finder methods live on AbstractEntity via EntityRepositoriesTrait — not on the repository. This means every entity that extends AbstractEntity gets them automatically.
// These work on any entity extending AbstractEntity
$player = Player::find( 42 );
$player = Player::findOneBy( [ 'email' => 'user@example.com' ] );
$players = Player::findBy( [ 'level' => 10 ], [ 'createdAt' => 'DESC' ] );
$players = Player::findAll();
$repo = Player::rep();
EntityRepositoriesTrait::setRepository() resolves the correct repository by calling em($connection)->getRepository(static::class) — it returns whatever Doctrine has registered for that entity, which could be a custom repository class, Doctrine's default EntityRepository, or anything in between.
The static finders exist because the framework's controllers don't have dependency injection. Without them, every lookup requires:
// Without static finders — works but verbose without DI
$player = em( 'main' )->getRepository( Player::class )->find( $id );
The 'main' connection shown above is for illustration only. In real projects the connection should always be a hardcoded named connection (em('main'), em('blog'), em('payments'), etc.) — never a runtime-derived value — so misconfigured access fails loud and early instead of silently hitting the wrong database.
With static finders on the entity that noise disappears. Both approaches hit the same Doctrine repository and produce identical results — static finders are purely a readability and convenience layer.
AbstractEntityRepository extends Doctrine's EntityRepository and adds exactly two methods:
final public function persist( AbstractEntityContract $entity, bool $flush = true ): void
{
$this->getEntityManager()->persist( $entity );
if ( $flush )
$this->getEntityManager()->flush();
}
final public function remove( AbstractEntityContract $entity, bool $flush = true ): void
{
$this->getEntityManager()->remove( $entity );
if ( $flush )
$this->getEntityManager()->flush();
}
That is the complete list of framework additions. If you want persist() and remove() available on your repository and via Entity::rep()->persist(), extend AbstractEntityRepository. If you don't need them, extend Doctrine's EntityRepository directly or skip a custom repository entirely — static finders still work either way.
Standard Symfony/Doctrine practice — create a repository class and register it on the entity:
// App/Repository/Main/PlayerRepository.php
namespace App\Repository\Main;
use App\Entity\Main\Player;
use PHP_SF\System\Classes\Abstracts\AbstractEntityRepository;
/**
* @extends AbstractEntityRepository<Player>
* @method Player|null find( $id, $lockMode = null, $lockVersion = null )
* @method Player|null findOneBy( array $criteria, array $orderBy = null )
* @method array|Player[] findAll()
* @method array|Player[] findBy( array $criteria, array $orderBy = null, $limit = null, $offset = null )
*/
final class PlayerRepository extends AbstractEntityRepository
{
public function __construct()
{
// Resolve class metadata from the live EntityManager — never construct
// a bare ClassMetadata(Player::class), which creates unregistered
// metadata that diverges from what Doctrine has mapped.
parent::__construct( em( 'main' ), em( 'main' )->getClassMetadata( Player::class ) );
}
}
// App/Entity/Main/Player.php
#[ORM\Entity( repositoryClass: PlayerRepository::class )]
#[ORM\Table( name: 'players' )]
class Player extends AbstractEntity { ... }
The @method docblocks are optional but give your IDE accurate return types for inherited Doctrine methods — without them find() is typed as object|null instead of Player|null.
If you don't need custom query methods or the persist()/remove() shortcuts, you don't need a custom repository at all. Doctrine's default repository is used and static finders still work.
Available on every entity extending AbstractEntity:
// Find by primary key → Entity|null (ID may be int OR string — for UUID/ULID PKs)
$player = Player::find( 42 );
$player = Player::find( '01HZ…ULID…' ); // works if Player declares a string PK
// Find one by criteria → Entity|null
$player = Player::findOneBy( [ 'email' => 'user@example.com' ] );
// Find multiple → array keyed by entity ID
$players = Player::findBy(
criteria: [ 'level' => 10 ],
orderBy: [ 'createdAt' => 'DESC' ],
limit: 20,
offset: 0
);
// Find all → array keyed by entity ID
$allPlayers = Player::findAll();
// Access the repository instance directly
$repo = Player::rep();
findBy() and findAll() return arrays keyed by entity ID — [ $id => $entity ]. This makes ID-based lookup and deduplication trivial without an extra pass.
Player::rep() returns the repository instance — useful for calling custom repository methods without going through em():
$topPlayers = Player::rep()->getTopPlayersByScore( 10 );
If the repository extends AbstractEntityRepository:
// Persist (insert or update) with immediate flush
Player::rep()->persist( $player );
// Without flush — batch multiple operations
Player::rep()->persist( $player, false );
Guild::rep()->persist( $guild, false );
Building::rep()->persist( $building, false );
em( 'main' )->flush();
// Remove with immediate flush
Player::rep()->remove( $player );
If the repository does not extend AbstractEntityRepository, use em() directly:
em( 'main' )->persist( $player );
em( 'main' )->flush();
em( 'main' )->remove( $player );
em( 'main' )->flush();
Both work identically — AbstractEntityRepository::persist() is just a shorthand that saves two lines.
Add custom methods to the repository class as you normally would in any Symfony project:
final class PlayerRepository extends AbstractEntityRepository
{
public function __construct()
{
parent::__construct( em( 'main' ), em( 'main' )->getClassMetadata( Player::class ) );
}
/**
* @return Player[]
*/
public function getTopPlayersByScore( int $limit = 10 ): array
{
return $this->createQueryBuilder( 'p' )
->orderBy( 'p.score', 'DESC' )
->setMaxResults( $limit )
->getQuery()
->getResult();
}
/**
* @return Player[]
*/
public function findByGuildAndMinLevel( int $guildId, int $minLevel ): array
{
return $this->createQueryBuilder( 'p' )
->join( 'p.guild', 'g' )
->where( 'g.id = :guildId' )
->andWhere( 'p.level >= :minLevel' )
->setParameter( 'guildId', $guildId )
->setParameter( 'minLevel', $minLevel )
->orderBy( 'p.level', 'DESC' )
->getQuery()
->getResult();
}
public function countActivePlayersSince( \DateTimeInterface $since ): int
{
return (int) $this->createQueryBuilder( 'p' )
->select( 'COUNT(p.id)' )
->where( 'p.lastLogin >= :since' )
->setParameter( 'since', $since )
->getQuery()
->getSingleScalarResult();
}
}
public function findPlayersWithAllies(): array
{
return $this->getEntityManager()->createQuery(
'SELECT p, a
FROM App\Entity\Player p
LEFT JOIN p.allies a
WHERE p.level > :level'
)
->setParameter( 'level', 5 )
->getResult();
}
use Doctrine\ORM\Query\ResultSetMappingBuilder;
public function getPlayerRankings(): array
{
$rsm = new ResultSetMappingBuilder( $this->getEntityManager() );
$rsm->addRootEntityFromClassMetadata( Player::class, 'p' );
return $this->getEntityManager()->createNativeQuery(
'SELECT p.*
FROM players p
WHERE p.score > (
SELECT AVG(score) FROM players
)
ORDER BY p.score DESC',
$rsm
)->getResult();
}
setRepository() resolves the correct entity manager for an entity by reading doctrine.yaml and matching the entity's namespace against the configured prefix for each entity manager mapping:
private static function setRepository(): void
{
$projectDir = Kernel::getInstance()->getProjectDir();
$yamlConfigCacheKey = '/config/packages/doctrine.yaml';
// The parsed config is cached as a JSON string; decode it on cache hit.
$config = ca()->get( $yamlConfigCacheKey );
if ( null === $config ) {
$config = yaml_parse_file( $projectDir . '/config/packages/doctrine.yaml' );
ca()->set( $yamlConfigCacheKey, json_encode( $config ) );
} else {
$config = json_decode( $config, true );
}
$entityManagers = $config['doctrine']['orm']['entity_managers'];
$namespace = substr( static::class, 0, strrpos( static::class, '\\' ) );
foreach ( $entityManagers as $connection => $entityManager )
foreach ( $entityManager['mappings'] as $mapping )
if ( $mapping['prefix'] === $namespace ) {
self::$repositories[ static::class ] = em( $connection )
->getRepository( static::class );
return;
}
throw new InvalidConfigurationException( 'Entity repository not found' );
}
The doctrine.yaml is parsed once and cached as a JSON string via ca() (Redis/APCu); later calls decode the cached JSON rather than re-parsing the YAML. The resolved repository instance is cached in the static $repositories array for the lifetime of the request — Player::find() called 10 times in one request resolves the repository only once.
The framework's default Doctrine config is intentionally a non-functional dummy placeholder — doctrine.yaml ships with default_connection: dummy / default_entity_manager: dummy using PHP_SF\System\Doctrine\ForbiddenDriver. Any code that accidentally uses the default connection fails fast and loudly during container compilation (via PHP_SF\System\Doctrine\ForbidDefaultDoctrinePass), forcing explicit use of named connections.
Add real connections in config/packages/doctrine.yaml (the three-name convention shown below matches the template project's ports — PostgreSQL :7003, MySQL :7005, MariaDB :7006):
doctrine:
dbal:
default_connection: dummy
connections:
dummy:
driver_class: PHP_SF\System\Doctrine\ForbiddenDriver
main:
url: '%env(resolve:DATABASE_MAIN_URL)%'
blog:
url: '%env(resolve:DATABASE_BLOG_URL)%'
payments:
url: '%env(resolve:DATABASE_PAYMENTS_URL)%'
orm:
default_entity_manager: dummy
entity_managers:
dummy:
connection: dummy
mappings:
Dummy:
type: attribute
dir: '%kernel.project_dir%/App/Entity/Dummy'
prefix: 'App\Entity\Dummy'
alias: Dummy
main:
connection: main
mappings:
Main:
type: attribute
dir: '%kernel.project_dir%/App/Entity/Main'
prefix: 'App\Entity\Main'
alias: Main
archive:
connection: archive
mappings:
Archive:
type: attribute
dir: '%kernel.project_dir%/App/Entity/Archive'
prefix: 'App\Entity\Archive'
alias: Archive
EntityRepositoriesTrait resolves the correct entity manager automatically based on the entity's namespace prefix (App\Entity\Main\... → main, App\Entity\Archive\... → archive, etc.). Static finders work across all configured connections without any extra setup.
Access a specific entity manager directly when needed — always with an explicit connection name:
em( 'main' ) // primary connection
em( 'archive' ) // secondary connection
Never use em('default') or rely on an implicit default — the dummy driver throws on first use, and the compiler pass rejects any service that injects the default EM. The connection name is always explicit.
For expensive queries that run frequently, cache the result. Each call to AbstractEntity::clearQueryBuilderCache() (triggered automatically on every entity insert/update/delete via Doctrine lifecycle callbacks) wipes any key matching *doctrine_result_cache:*, so that prefix is the natural namespace for ORM-result caches.
Caveat: the wildcard pattern matches any key containing
doctrine_result_cache:. If two subsystems both pick that prefix they will evict each other. For application-level caches that the framework does not need to invalidate, prefer a different prefix — e.g.doctrine_user_cache:— so cache keys remain app-scoped.
public function getTopPlayersByScore( int $limit = 10 ): array
{
// Safe: framework only clears `*doctrine_result_cache:*`, so this prefix is preserved.
$cacheKey = sprintf( 'doctrine_user_cache:top_players_%d', $limit );
if ( ca()->has( $cacheKey ) )
return j_decode( ca()->get( $cacheKey ), true );
$result = $this->createQueryBuilder( 'p' )
->orderBy( 'p.score', 'DESC' )
->setMaxResults( $limit )
->getQuery()
->getResult();
ca()->set(
$cacheKey,
j_encode( array_map( fn( Player $p ) => $p->jsonSerialize(), $result ) ),
300
);
return $result;
}
| Situation | Use |
|---|---|
| Find by ID | Entity::find( $id ) |
| Find by criteria | Entity::findOneBy( [...] ) |
| Find multiple with criteria | Entity::findBy( [...] ) |
| Fetch all records of a small table | Entity::findAll() |
| Complex query with joins or aggregations | Custom repository method |
| Database-specific SQL | Repository native query |
| Persist or remove (with shorthand) | Entity::rep()->persist() / Entity::rep()->remove() |
| Persist or remove (without AbstractEntityRepository) | em('main')->persist() / em('main')->flush() |
| In a Symfony service with DI available | Injected repository or em('main')->getRepository() |
PHP_SF note: the last row only applies to Symfony-style projects that wire Doctrine through DI. PHP_SF controllers do not receive injected repositories by default — call the static finders (
Entity::find(…),Entity::findOneBy(…),Entity::findBy(…)) directly from anywhere in the codebase. Inject repositories manually only inside Symfony service classes (App/Service/...that are autoconfigured throughservices.yaml).
Assuming static finders require a custom repository — they don't. Static finders come from AbstractEntity via EntityRepositoriesTrait and work with Doctrine's default repository if no custom one is defined. The repository class only matters for custom query methods and the persist()/remove() shortcuts.
Forgetting flush: false on batched operations — AbstractEntityRepository::persist() flushes after every call by default. Persisting many entities in a loop without false means one database round trip per entity. Pass false and flush once at the end.
Namespace mismatch in multi-connection setup — setRepository() matches the entity's namespace against mapping.prefix in doctrine.yaml. A mismatch throws InvalidConfigurationException. Verify the entity's namespace declaration exactly matches the configured prefix.
Mixing entity managers in one transaction — each entity manager has its own unit of work. em('main') and em('blog') are separate transactions. Cross-connection atomicity requires manual transaction management.