Extending

The bundle’s strict layering makes it predictable to extend. Whether you are adding a brand-new ESPN resource or customizing how an existing one is imported, you follow the same five-layer pattern the bundle uses everywhere.

Adding a new resource

Suppose ESPN exposes a resource the bundle does not yet import. To add it, you create up to six classes — the same set every existing resource has.

1. Entity

Create a Doctrine entity following the conventions in Entities: an espnId column, a bigint identity key, nullable columns, the SyncTimestampsTrait, and real associations to related entities. Store links to other resources as {name}Reference columns.

#[ORM\Entity(repositoryClass: EspnFooRepository::class)]
#[ORM\Table(name: 'easb_espn_foo')]
#[ORM\HasLifecycleCallbacks]
class EspnFoo
{
    use SyncTimestampsTrait;

    #[ORM\Id]
    #[ORM\GeneratedValue(strategy: 'IDENTITY')]
    #[ORM\Column(type: 'bigint')]
    private ?int $id = null;

    #[ORM\Column(nullable: true)]
    private ?string $espnId = null;

    // ... scalar columns, reference columns, associations ...
}

2. Repository

Add a findByDtoOrCreateEntity() style method that finds the existing row by its natural key or returns a new entity, keeping imports idempotent.

public function findByDtoOrCreateEntity(EspnFooDto $dto): EspnFoo
{
    return $this->findOneBy(['espnId' => $dto->getId()]) ?? new EspnFoo();
}

3. Converter

Map the DTO’s scalars and reference strings onto the entity — nothing else. No entity-to-entity connections here.

public function toEntity(EspnFooDto $dto): EspnFoo
{
    $entity = $this->repository->findByDtoOrCreateEntity($dto);

    $entity->setEspnId($dto->getId());
    $entity->setDisplayName($dto->getDisplayName());
    $entity->setBarReference($dto->getBarReference());

    return $entity;
}

4. Importer

Resolve the resource’s identifiers from its reference, fetch the DTO through the client, call the converter, then connect related entities. This is the only place connections happen.

public function buildEntityFromReference(string $reference): EspnFoo
{
    $params = EspnUrlPatternResolver::resolveAll(
        $reference,
        EspnUrlPatternResolver::URL_PATTERN_FOO
    );

    if (null === $params->fooId) {
        throw new UnrecoverableImportException(
            sprintf('Could not resolve fooId from: %s', $reference)
        );
    }

    $dto = $this->espnApiClient->foos()->get($params->fooId);

    if (!$dto) {
        // primary resource missing from API -> retryable
        throw new ImportException(sprintf('Foo %d not found', $params->fooId));
    }

    $entity = $this->converter->toEntity($dto);

    // connect a parent that must already exist -> unrecoverable if missing
    $this->connectBar($entity, $dto->getBarReference());

    return $entity;
}

Use UnrecoverableImportException for URL-resolution and parent-not-found-in-DB cases, and a plain ImportException for the primary resource missing from the API. See Error handling.

5. Message

A readonly message carrying the reference, any parent entity ids, and the import-control array.

class ImportEspnFooMessage
{
    public function __construct(
        public readonly string $reference,
        public readonly ?array $importEntities = null,
    ) {}
}

6. Handler

Persist the entity, flush, dispatch children, and apply the two-arm catch from Error handling.

#[AsMessageHandler]
class ImportEspnFooMessageHandler
{
    use ImportEntitiesHelperTrait;

    public function __invoke(ImportEspnFooMessage $message): void
    {
        try {
            $foo = $this->importer->buildEntityFromReference($message->reference);
            $this->entityManager->persist($foo);
            $this->entityManager->flush();
            // dispatch children guarded by $this->shouldImport(...)
        } catch (UnrecoverableImportException $e) {
            $this->importLogger->critical(/* ... */);
            throw new UnrecoverableMessageHandlingException($e->getMessage(), previous: $e);
        } catch (\Throwable $e) {
            $this->importLogger->warning(/* ... */);
            throw $e;
        }
    }
}

Finally, register the new converter, importer, repository, and handler in the bundle’s service YAML, route the message in messenger.yaml, and run make:migration.

Connecting from the owning side

When you connect a bidirectional association in an importer, set it from the owning side so Doctrine writes the foreign key, and let the entity’s setter keep both sides consistent. The bundle’s OneToOne setters use a recursion-guarded pattern:

public function setTeam(?EspnTeam $team): static
{
    $this->team = $team;
    if (null !== $team && $team->getFranchise() !== $this) {
        $team->setFranchise($this);   // keep the owning side in sync
    }
    return $this;
}

The guard (!== $this) prevents infinite recursion between the two setters.

Adding a flag

If your new resource should be optional in the cascade, add a constant to EspnImportService and gate its dispatch with shouldImport():

// in EspnImportService
public const IMPORT_ENTITY_FOO = 'import_entity_foo';

// in the parent handler, before dispatching the foo message
if ($this->shouldImport($importEntities, EspnImportService::IMPORT_ENTITY_FOO)) {
    $this->messageBus->dispatch(new ImportEspnFooMessage($fooRef, $importEntities));
}

Add it to getSeasonImportEntities() (or the relevant default set) if it should run by default. If the resource is always imported alongside its parent (like notes), skip the flag and import it unconditionally in the importer.

Numeric-as-string fields

If a new ESPN field arrives as a JSON number but you store it as a string, add the type-enforcement-disabling context to the DTO property (in the client package), exactly as the existing DTOs do. The entity column can then be a plain string or a decimal.

Reserved SQL words

If a column name collides with a SQL reserved word (order, user …), map it to a safe column name explicitly:

#[ORM\Column(name: 'display_order', nullable: true)]
private ?int $displayOrder = null;