PHPStan Extension (DX-03)¶
Three static-analysis rules for PHPStan Level 9 projects: tenancy.mutualExclusion catches
the forbidden #[Shared] + #[TenantAware] combination on a single class; tenancy.sharedEntityLeak
catches queries against a landlord-side master #[Shared] entity routed through the tenant
EntityManager; tenancy.tenantIdDrift catches #[TenantAware] entities with a missing,
nullable, or non-string tenant_id column.
This page covers installation, all three rules with violation examples and fixes, and the
checkSharedEntityLeaks parameter.
Overview¶
The extension ships three PHPStan rules that complement the runtime guards in the bundle:
- Rule 1 —
tenancy.mutualExclusion: edit-time version ofSharedEntityMutualExclusionPass. Catches the contradictory#[Shared]+#[TenantAware]combination on the same class at analysis time, without requiring atenancy.shared_entitycontainer tag. - Rule 2 —
tenancy.sharedEntityLeak: catches queries routing a landlord-side master#[Shared]entity through the tenant EntityManager. A#[Shared]entity is a landlord-side master; reading it through the tenant EM risks returning the wrong tenant-side read-only copy or leaking landlord master data. - Rule 3 —
tenancy.tenantIdDrift: catches#[TenantAware]entities whosetenant_idcolumn is absent, nullable, or typed as a non-string Doctrine type — a latent cross-tenant data leak that the SQL filter cannot handle correctly.
Installation¶
extension-installer reads composer.json#extra.phpstan.includes and automatically
includes extension.neon. All three rules run on the next phpstan analyse — no
phpstan.neon changes required.
Add the extension neon to your includes block:
When you also use phpstan/phpstan-doctrine, include the doctrine-aware fragment instead:
This fragment injects ObjectMetadataResolver for Rule 3's full ORM metadata path
(entities mapped via XML or YAML, not only PHP attributes).
Never include both extension.neon and extension-doctrine.neon
Both files register all three rules. PHPStan does not deduplicate tagged rules — including
both causes every error to fire twice. Use extension-doctrine.neon instead of
extension.neon when phpstan/phpstan-doctrine is installed, never in addition to it.
Rule 1: Mutual Exclusion (tenancy.mutualExclusion)¶
Fires when a single class carries both #[Shared] and #[TenantAware]. A #[Shared] entity
is a landlord-side master record synced to all tenants; a #[TenantAware] entity is
tenant-scoped data filtered by TenantAwareFilter. These two roles are mutually exclusive.
<?php
// VIOLATION: both attributes on the same class
use Tenancy\Bundle\Attribute\Shared;
use Tenancy\Bundle\Attribute\TenantAware;
#[Shared]
#[TenantAware]
class Plan
{
// ERROR: Entity App\Entity\Plan cannot carry both #[Shared] and #[TenantAware].
// A shared entity is a landlord-side master; a TenantAware entity is tenant-scoped.
// Pick one.
}
<?php
// FIX: pick one attribute
use Tenancy\Bundle\Attribute\Shared;
#[Shared]
class Plan
{
// landlord-side master — synced to all tenants as a read-only copy
}
Rule 1 fires unconditionally — it is not gated by checkSharedEntityLeaks.
Rule 2: Shared Entity Leak (tenancy.sharedEntityLeak)¶
Fires when the concrete Doctrine\ORM\EntityManager class (not the EntityManagerInterface)
calls find(), getReference(), or getRepository() with a #[Shared] entity class as the
first argument. A #[Shared] entity is a landlord-side master; querying it through the tenant
EntityManager risks operating on the wrong tenant-side read-only copy.
<?php
// VIOLATION: concrete EntityManager (not interface) querying a #[Shared] entity
use Doctrine\ORM\EntityManager;
use App\Entity\Plan;
class PlanService
{
public function __construct(private EntityManager $em) {}
public function find(int $id): ?Plan
{
return $this->em->find(Plan::class, $id);
// ERROR: Entity App\Entity\Plan is #[Shared]. Querying it through the tenant
// EntityManager risks a cross-tenant data leak. Route the query through
// the named landlord EntityManager.
}
}
<?php
// FIX A: inject the named landlord EM using MapEntity
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
class PlanService
{
public function __construct(
#[MapEntity(entityManagerName: 'landlord')]
private EntityManagerInterface $landlordEm,
) {}
public function find(int $id): ?Plan
{
return $this->landlordEm->find(Plan::class, $id);
}
}
<?php
// FIX B: suppress per-site when you have verified the query is on the landlord path
/** @phpstan-ignore tenancy.sharedEntityLeak */
return $this->em->find(Plan::class, $id);
Rule 2 is conservative by design. It fires only when the caller type is the concrete
Doctrine\ORM\EntityManager class. Interface-typed callers (EntityManagerInterface) are
silent — PHPStan cannot statically distinguish the landlord EM from the tenant EM when both
are typed as the interface. Rename your injection target or suppress per-site.
Disabling Rule 2 project-wide
This disables Rule 2 (tenancy.sharedEntityLeak) only. Rules 1 and 3 fire unconditionally
regardless of this setting.
Rule 3: Tenant ID Drift (tenancy.tenantIdDrift)¶
Fires when a #[TenantAware] entity has a tenant_id column that is missing, nullable, or
typed as a non-string Doctrine type. TenantAwareFilter constructs the SQL constraint as
{alias}.tenant_id = '<slug>' — a non-nullable string comparison. A missing, nullable, or
integer-typed column produces incorrect SQL or a runtime error.
<?php
// VIOLATION A: missing tenant_id column entirely
use Tenancy\Bundle\Attribute\TenantAware;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[TenantAware]
class Invoice
{
#[ORM\Column]
private int $amount;
// ERROR: Class App\Entity\Invoice is #[TenantAware] but has no column mapped to tenant_id.
}
<?php
// VIOLATION B: tenant_id is nullable
#[ORM\Entity]
#[TenantAware]
class Invoice
{
#[ORM\Column(length: 63, nullable: true)]
private ?string $tenantId;
// ERROR: tenant_id on App\Entity\Invoice is nullable — nullable tenant_id prevents scoping.
}
<?php
// VIOLATION C: non-string Doctrine type
#[ORM\Entity]
#[TenantAware]
class Invoice
{
#[ORM\Column(type: 'integer')]
private int $tenantId;
// ERROR: tenant_id on App\Entity\Invoice has type 'integer' — non-string type is
// incompatible with TenantAwareFilter's quoted-string comparison.
}
<?php
// FIX: non-nullable string column
#[ORM\Entity]
#[TenantAware]
class Invoice
{
#[ORM\Column(length: 63)]
private string $tenantId;
}
Accepted Doctrine types for tenant_id (case-insensitive): string, ascii_string, guid,
uuid. No length assertion is made — any string-family type is accepted.
Rule 3 fires unconditionally — it is not gated by checkSharedEntityLeaks.
See also¶
- Shared Entities —
#[Shared]attribute sync model; a landlord-side master fanned out as a tenant-side read-only copy to every tenant database. - Shared-DB Driver —
driver: shared_dbisolation strategy (SQL filter approach; distinct from#[Shared]entity sync). - Strict Mode — handling non-tenant contexts safely with
#[TenantAware]. - Configuration Reference —
tenancy.shared.*keys.