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( 'connection_name' )->getRepository( Player::class )->find( $id );
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( AbstractEntity $entity, bool $flush = true ): void
{
$this->getEntityManager()->persist( $entity );
if ( $flush )
$this->getEntityManager()->flush( $entity );
}
final public function remove( AbstractEntity $entity, bool $flush = true ): void
{
$this->getEntityManager()->remove( $entity );
if ( $flush )
$this->getEntityManager()->flush( $entity );
}
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/PlayerRepository.php
namespace App\Repository;
use App\Entity\Player;
use Doctrine\ORM\Mapping\ClassMetadata;
use PHP_SF\System\Classes\Abstracts\AbstractEntityRepository;
/**
* @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()
{
parent::__construct( em( 'connection_name' ), new ClassMetadata( Player::class ) );
}
}
// App/Entity/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
$player = Player::find( 42 );
// 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( 'connection_name' )->flush();
// Remove with immediate flush
Player::rep()->remove( $player );
If the repository does not extend AbstractEntityRepository, use em() directly:
em( 'connection_name' )->persist( $player );
em( 'connection_name' )->flush();
em( 'connection_name' )->remove( $player );
em( 'connection_name' )->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( 'connection_name' ), new ClassMetadata( 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
{
$config = ca()->get( '/config/packages/doctrine.yaml' )
?? yaml_parse_file( project_dir() . '/config/packages/doctrine.yaml' );
$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 cached in Redis/APCu after first parse. 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.
Works exactly as in standard Symfony. Configure connections and entity managers in config/packages/doctrine.yaml:
doctrine:
dbal:
default_connection: default
connections:
default:
url: '%env(resolve:DATABASE_URL)%'
analytics:
url: '%env(resolve:ANALYTICS_DATABASE_URL)%'
orm:
entity_managers:
default:
connection: default
mappings:
App:
type: attribute
dir: '%kernel.project_dir%/App/Entity'
prefix: 'App\Entity'
alias: App
analytics:
connection: analytics
mappings:
Analytics:
type: attribute
dir: '%kernel.project_dir%/App/Analytics/Entity'
prefix: 'App\Analytics\Entity'
alias: Analytics
EntityRepositoriesTrait resolves the correct entity manager automatically based on the entity's namespace prefix. Static finders work across all configured connections without any extra setup.
Access a specific entity manager directly when needed:
em( 'default' ) // default connection (connection name must always be explicit)
em( 'analytics' ) // analytics connection
For expensive queries that run frequently, cache the result using the doctrine_result_cache: key prefix. This prefix is cleared automatically by AbstractEntity::clearQueryBuilderCache() on every entity write — no manual cache invalidation needed:
public function getTopPlayersByScore( int $limit = 10 ): array
{
$cacheKey = sprintf( 'doctrine_result_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('connection_name')->persist() / em('connection_name')->flush() |
| In a Symfony service with DI available | Injected repository or em('connection_name')->getRepository() |
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('default') and em('analytics') are separate transactions. Cross-connection atomicity requires manual transaction management.