Skip to main content

Code Examples

The examples/ directory contains 15 self-contained PHP files covering every feature of the bundle. Each file includes inline comments, configuration snippets, and ready-to-use code.


Quick Reference

#FileFeature
01entity-setup.phpTenant config entity with TenantDbConfigTrait
02bundle-configuration.phpComplete YAML config reference
03tenant-entities.phpTenant-scoped entities (Product, Order)
04database-lifecycle.phpCreate DB, switch, CRUD, list/filter
05tenant-migrations.phpPlatform-agnostic migrations
06resolvers.phpAll 5 resolver strategies
07events.phpAll 6 lifecycle event subscribers
08custom-config-provider.phpRedis, static, in-memory providers
09tenant-fixtures.php#[TenantFixture] attribute + CLI
10tenant-aware-cache.phpCache isolation
11tenant-context.phpTenantContextInterface usage
12testing.phpTenantTestTrait patterns
13shared-entities.php#[TenantShared] attribute
14custom-resolver.phpJWT, query param, API key resolvers
15full-onboarding-flow.phpEnd-to-end tenant onboarding service

1. Tenant Entity Setup

Your application needs an entity that stores the connection details for each tenant database. Use TenantDbConfigTrait for the standard fields and implement TenantDbConfigurationInterface.

use Doctrine\ORM\Mapping as ORM;
use Hakam\MultiTenancyBundle\Services\TenantDbConfigurationInterface;
use Hakam\MultiTenancyBundle\Traits\TenantDbConfigTrait;

#[ORM\Entity]
#[ORM\Table(name: 'tenant_db_config')]
class TenantDbConfig implements TenantDbConfigurationInterface
{
use TenantDbConfigTrait;

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

// IMPORTANT: PHP method names are case-insensitive.
// The trait defines getDbUserName() and the interface defines getDbUsername().
// Access the property directly to avoid infinite recursion.
public function getDbUsername(): ?string
{
return $this->dbUserName;
}

public function getIdentifierValue(): mixed
{
return $this->id;
}
}

See full example: examples/01-entity-setup.php


2. Full Bundle Configuration

The complete YAML configuration with every available option:

hakam_multi_tenancy:
tenant_database_className: App\Entity\TenantDbConfig
tenant_database_identifier: id

tenant_connection:
url: '%env(DATABASE_URL)%'
host: '127.0.0.1'
port: '3306'
driver: pdo_mysql
charset: utf8
server_version: '8.0'

tenant_migration:
tenant_migration_namespace: DoctrineMigrations\Tenant
tenant_migration_path: '%kernel.project_dir%/migrations/Tenant'

tenant_entity_manager:
tenant_naming_strategy: doctrine.orm.naming_strategy.default
mapping:
type: attribute
dir: '%kernel.project_dir%/src/Entity/Tenant'
prefix: App\Entity\Tenant
alias: Tenant
is_bundle: false

resolver:
enabled: true
strategy: header
throw_on_missing: false
excluded_paths: ['/health', '/api/public', '/_profiler']
options:
header_name: X-Tenant-ID

cache:
enabled: true
prefix_separator: '__'

# Optional: custom provider override
# tenant_config_provider: app.my_custom_tenant_provider

See full example: examples/02-bundle-configuration.php


3. Tenant Entities

Tenant entities live in a separate directory (e.g., src/Entity/Tenant/) and are managed by the tenant entity manager:

namespace App\Entity\Tenant;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\Table(name: 'product')]
class Product
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;

#[ORM\Column(type: 'string', length: 255)]
private string $name = '';

#[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
private string $price = '0.00';

// ...getters/setters
}

See full example: examples/03-tenant-entities.php


4. Database Lifecycle

The full lifecycle: register tenant, create database, switch connection, CRUD operations.

// Register a new tenant
$tenantDto = $this->tenantManager->addNewTenantDbConfig(
TenantConnectionConfigDTO::fromArgs(
identifier: null,
driver: DriverTypeEnum::MYSQL,
dbStatus: DatabaseStatusEnum::DATABASE_NOT_CREATED,
host: '127.0.0.1', port: 3306,
dbname: 'tenant_acme', user: 'root', password: 'secret',
)
);

// Create the database
$this->tenantManager->createTenantDatabase($tenantDto);

// Switch to it
$this->dispatcher->dispatch(new SwitchDbEvent((string) $tenantDto->identifier));

// CRUD with the tenant entity manager
$product = new Product();
$product->setName('Widget Pro');
$this->tenantEntityManager->persist($product);
$this->tenantEntityManager->flush();

CLI commands:

php bin/console tenant:database:create --dbid=42     # Single tenant
php bin/console tenant:database:create --all # All missing
php bin/console tenant:migrations:migrate init 42 # Migrate one
php bin/console tenant:migrations:migrate init # Migrate all new
php bin/console tenant:migrations:migrate update # Update existing

See full example: examples/04-database-lifecycle.php


5. Tenant Migrations

Use Doctrine's Schema API for platform-agnostic migrations (works on MySQL and PostgreSQL):

namespace DoctrineMigrations\Tenant;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20240101000000 extends AbstractMigration
{
public function up(Schema $schema): void
{
$table = $schema->createTable('product');
$table->addColumn('id', 'integer', ['autoincrement' => true]);
$table->addColumn('name', 'string', ['length' => 255]);
$table->addColumn('price', 'decimal', ['precision' => 10, 'scale' => 2]);
$table->setPrimaryKey(['id']);
}

public function down(Schema $schema): void
{
$schema->dropTable('product');
}
}

See full example: examples/05-tenant-migrations.php


6. Tenant Resolvers

Five built-in strategies for automatic tenant resolution from HTTP requests:

# Header (APIs)
resolver: { enabled: true, strategy: header }

# Subdomain (SaaS)
resolver: { enabled: true, strategy: subdomain, options: { base_domain: example.com } }

# Path (/tenant-id/page)
resolver: { enabled: true, strategy: path }

# Host mapping (custom domains)
resolver: { enabled: true, strategy: host, options: { host_map: { client.com: tenant1 } } }

# Chain (fallback)
resolver: { enabled: true, strategy: chain, options: { chain_order: [header, path] } }

With resolvers enabled, controllers need no manual switching:

class ProductController extends AbstractController
{
public function list(TenantContextInterface $tenantContext): JsonResponse
{
// Resolver already switched the DB! Just query.
$tenantId = $tenantContext->getTenantId();
$products = $this->tenantEntityManager->getRepository(Product::class)->findAll();
return new JsonResponse(['tenant' => $tenantId, 'count' => count($products)]);
}
}

See full example: examples/06-resolvers.php


7. Lifecycle Events

Subscribe to events fired during tenant operations:

class TenantLifecycleSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
TenantCreatedEvent::class => 'onCreated', // DB created
TenantMigratedEvent::class => 'onMigrated', // Migrations applied
TenantBootstrappedEvent::class=> 'onBootstrapped', // Fixtures loaded
TenantSwitchedEvent::class => 'onSwitched', // Connection switched
TenantDeletedEvent::class => 'onDeleted', // DB dropped
];
}

public function onCreated(TenantCreatedEvent $event): void
{
// $event->getDatabaseName(), $event->getTenantIdentifier()
}

public function onMigrated(TenantMigratedEvent $event): void
{
// $event->getMigrationType() — 'init' or 'update'
// $event->isInitialMigration(), $event->getToVersion()
}

public function onSwitched(TenantSwitchedEvent $event): void
{
// $event->getPreviousTenantIdentifier(), $event->hadPreviousTenant()
}
}

See full example: examples/07-events.php


8. Custom Config Provider

Replace the default Doctrine-based provider with your own:

use Hakam\MultiTenancyBundle\Port\TenantConfigProviderInterface;

class RedisTenantConfigProvider implements TenantConfigProviderInterface
{
public function getTenantConnectionConfig(mixed $identifier): TenantConnectionConfigDTO
{
$data = $this->redis->hGetAll("tenant:{$identifier}");
return TenantConnectionConfigDTO::fromArgs(
identifier: $identifier,
driver: DriverTypeEnum::from($data['driver']),
dbStatus: DatabaseStatusEnum::from($data['status']),
host: $data['host'], port: (int) $data['port'],
dbname: $data['dbname'], user: $data['user'],
password: $data['password'],
);
}
}
# config/packages/hakam_multi_tenancy.yaml
hakam_multi_tenancy:
tenant_config_provider: App\Service\RedisTenantConfigProvider

See full example: examples/08-custom-config-provider.php


9. Tenant Fixtures

Mark fixtures with #[TenantFixture] to load them into tenant databases:

use Hakam\MultiTenancyBundle\Attribute\TenantFixture;
use Doctrine\Bundle\FixturesBundle\Fixture;

#[TenantFixture]
class ProductFixture extends Fixture
{
public function load(ObjectManager $manager): void
{
$product = new Product();
$product->setName('Basic Plan');
$product->setPrice('9.99');
$manager->persist($product);
$manager->flush();
}
}
php bin/console tenant:fixtures:load 42            # Specific tenant
php bin/console tenant:fixtures:load --append # All, without purging
php bin/console tenant:fixtures:load --group=demo # By group

See full example: examples/09-tenant-fixtures.php


10. Tenant-Aware Cache

Enable cache isolation to prefix keys with the active tenant ID automatically:

hakam_multi_tenancy:
cache:
enabled: true
class ProductCatalogService
{
public function getCatalog(CacheInterface $cache): array
{
// Key "product_catalog" becomes "42__product_catalog" for tenant 42
return $cache->get('product_catalog', fn() => $this->buildCatalog());
}
}

See full example: examples/10-tenant-aware-cache.php


11. Tenant Context

Access the current tenant ID anywhere via TenantContextInterface:

class AuditService
{
public function __construct(private TenantContextInterface $tenantContext) {}

public function log(string $action): void
{
$tenantId = $this->tenantContext->getTenantId(); // null if no tenant active
$this->logger->info($action, ['tenant' => $tenantId]);
}
}

See full example: examples/11-tenant-context.php


12. Testing

Use TenantTestTrait for PHPUnit tests:

use Hakam\MultiTenancyBundle\Test\TenantTestTrait;

class ProductServiceTest extends KernelTestCase
{
use TenantTestTrait;

public function testTenantIsolation(): void
{
// runInTenant() switches, runs callback, resets state automatically
$this->runInTenant('tenant_a', function () {
$em = $this->getTenantEntityManager();
$product = new Product();
$product->setName('Tenant A Product');
$em->persist($product);
$em->flush();
});

$this->runInTenant('tenant_b', function () {
$products = $this->getTenantEntityManager()
->getRepository(Product::class)->findAll();
$this->assertCount(0, $products); // Isolated!
});
}
}

See full example: examples/12-testing.php


13. Shared Entities

Mark entities shared across tenants with optional exclusions:

use Hakam\MultiTenancyBundle\Attribute\TenantShared;

#[TenantShared]
#[ORM\Entity]
class Plan { /* shared across ALL tenants */ }

#[TenantShared(excludeTenants: ['free_tier'], group: 'premium')]
#[ORM\Entity]
class PremiumFeature { /* shared, but not for free_tier */ }
// Check access at runtime:
$attr = (new \ReflectionClass($entityClass))->getAttributes(TenantShared::class);
$tenantShared = $attr[0]->newInstance();
$canAccess = $tenantShared->isAvailableForTenant($currentTenantId);

See full example: examples/13-shared-entities.php


14. Custom Resolver

Implement TenantResolverInterface for custom resolution logic:

class JwtTenantResolver implements TenantResolverInterface
{
public function resolve(Request $request): ?string
{
$token = substr($request->headers->get('Authorization', ''), 7);
$payload = json_decode(base64_decode(explode('.', $token)[1] ?? ''), true);
return $payload['tenant_id'] ?? null;
}

public function supports(Request $request): bool
{
return $request->headers->has('Authorization');
}
}

See full example: examples/14-custom-resolver.php


15. Full Onboarding Flow

Complete end-to-end tenant setup: config entity, create DB, migrate, load fixtures, verify:

class TenantOnboardingService
{
public function onboard(string $companyName, string $databaseName): array
{
// 1. Create config entity in main DB
$config = new TenantDbConfig();
$config->setDbName($databaseName);
$this->entityManager->persist($config);
$this->entityManager->flush();

// 2. Create the actual database
$this->tenantManager->createTenantDatabase($dto);
$this->tenantManager->updateTenantDatabaseStatus($id, DatabaseStatusEnum::DATABASE_CREATED);

// 3. Run migrations
$this->runCommand('tenant:migrations:migrate', ['type' => 'init', 'dbId' => $id]);

// 4. Load fixtures
$this->runCommand('tenant:fixtures:load', ['dbId' => $id, '--append' => true]);

return ['tenant_id' => $id, 'status' => 'ready'];
}
}

See full example: examples/15-full-onboarding-flow.php