Building a Multi-Tenant Application with CodeIgniter 4

I work with multiple programming languages, manage engineering teams, and solve problems. I am a Capricorn. I approach complex problems by finding solutions through teamwork. I enjoy hearing fresh and creative ideas.
With over 5 years of software engineering experience, I have worked on many projects and led different engineering teams in small companies (5-20 employees).
I specialize in mobile and web development, working on both user interfaces and behind-the-scenes systems.
I would like to connect and learn about you!
You are building a SaaS, and your choice of programming language is PHP. Then you decide to use CodeIgniter 4. Now you have one codebase, many customers in mind, and a hard rule: customer A can never see customer B's data. Not by bug, not by misconfigured cache key, not by a forgotten WHERE clause. How you enforce that rule is the first architectural decision you will get wrong if you are not careful.
Microsoft's Azure architecture guide frames isolation as a spectrum rather than a binary. At one end, a single shared database with rows tagged by tenant_id At the other end, a dedicated database per tenant. AWS calls the same shape "pooled" versus "silo" or "hybrid." More sharing means lower per-tenant cost and simpler scaling, but one misconfigured control and every tenant's data is in the headlines. More isolation means stronger compliance and easier per-tenant backup and restore, but higher operational overhead. Where you land on that spectrum is the call that shapes everything else.
This tutorial builds toward the isolated end. We are going to stand up a CodeIgniter 4 application where each tenant gets its own MySQL database, identified by subdomain, with migrations fanned out automatically and a worker context that does not leak between tenants. The tool that does the heavy lifting is nuelcyoung/tenantable, and the honest parts, what it gets right, where it cuts corners, what you still have to handle yourself, are in here too, in this article.
Before we start, make sure you have the following:
Prerequisites
PHP 8.1 or higher
Composer for managing PHP dependencies
MySQL/MariaDB (the auto-provisioning path is MySQL-only)
A local dev domain like
myapp.test(Herd/Valet on macOS; Acrylic DNS on Windows)Basic knowledge of CodeIgniter 4
Why CodeIgniter 4
A recurring sentiment in the CI4 community is that it trades ecosystem size for speed and supply-chain control. Developers who benchmarked it against Symfony report it as the fastest on identical workloads; defenders describe it as "ridiculously fast" with "fewer external dependencies." That is not a free lunch; you write more glue yourself, and there is no first-party multi-tenant package waiting in the framework. But CI4's smaller bootstrap and fewer auto-resolved services mean a lower fixed overhead per request, which matters under load. The supply-chain point matters here in a specific way: in a multi-tenant system, one vulnerable transitive dependency is every tenant's headline. A learner dependency tree is a real defence.
So CI4 is the base. Now let's talk about what you are actually building on top of it, because database-per-tenant is not a free lunch either.
What database-per-tenant actually costs you
Most tenancy tutorials skip the sharp edges. Database-per-tenant has several, and you should know them before committing rather than discovering them in production.
Start with migrations. MySQL DDL implicitly commits, so CREATE TABLE Inside a transaction cannot be rolled back. If a migration fails on tenant 7 of 20, tenants 1 through 6 are already migrated, and you have no atomic way to undo them. PostgreSQL supports transactional DDL; MySQL does not. The practical answer is lazy, per-tenant migration tracking, which we will set up below, not "migrate all in one transaction."
Then there is connection pooling, or the lack of it. The package holds one connection per active request, not a pool. That is fine for hundreds of tenants on a single MySQL instance. It is not fine for 100k tenants without sharding or a proxy like ProxySQL. Know your scale before you commit.
One default you should change immediately: strictTenantIsolation ships off. It makes row-isolation refuse to run queries when no tenant context is active, rather than silently scanning every tenant's rows. It defaults to false for backward compatibility, which means most upgraded installs run the leakiest strategy in its leakiest mode without realising it. If you ever use row isolation, enable it.
And a point about the security middleware that is easy to misread. TenantSecurityMiddleware strips a mismatch tenant_id from POST/GET and logs it. That is, pragmatic real apps do put tenant_id in forms, but the correct architecture is to derive tenant_id server-side from the resolved tenant context and never read it from client input at all. If a middleware has to scrub it, the input contract is already wrong. Use the middleware as defence-in-depth, not as your primary isolation.
What the package does get right is the disposition: fail-closed, where it could fail open, refuse to emit driver-invalid DDL, and test the actual attack surfaces (HostHeaderInjectionTest.php, TenantRowIsolationTest.php). The argument for depending on it is not any single feature that a competent dev could not write in an afternoon. It is that the author has already mapped the failure surfaces, and the tests stay green when you stop paying attention.
With the costs on the table, let's build the thing.
Setting up the project
Create a new CodeIgniter 4 project and install the package:
composer create-project codeigniter4/appstarter multi-tenant-saas
cd multi-tenant-saas
composer require nuelcyoung/tenantable
Run the installer. It is interactive on first run and asks for your base domain, isolation mode, and identification strategy. It publishes app/Config/Tenantable.php, wires app/Config/Filters.php , and app/Config/Events.php idempotently, and registers a migration for the central tenants table:
php spark tenants:install
For non-interactive or CI installs:
php spark tenants:install --base-domain=myapp.test --mode=database --strategy=tenant_subdomain --yes
Five identification filters plus a separate guard middleware are auto-registered through CI4's Config\Registrar: tenant_subdomain, tenant_domain, tenant_domain_or_subdomain, tenant_path, tenant_request, and tenant_security. You do not need to add them to $aliases yourself — the installer handled it.
Next, the database.
Setting up the database
Update your .env credentials with MySQL. The DB user must have CREATE privileges because the package runs CREATE DATABASE IF NOT EXISTS tenant_{id} when a new tenant is provisioned. Credentials come from .env only; only the database name changes per tenant.
database.default.hostname = localhost
database.default.database = central
database.default.username = root
database.default.password = secret
database.default.DBDriver = MySQLi
Run the central migration that the installer registered. This creates the tenants and tenant_domains tables in your central database:
php spark migrate -g tenantable
With the central schema in place, we can tell the package how to isolate tenants.
Configuring isolation
Edit app/Config/Tenantable.php:
public string $baseDomain = 'myapp.test';
public bool $separateDatabasePerTenant = true;
public ?string $isolationMode = 'database';
public ?string $tenantMigrationsNamespace = 'App\Database\Migrations\Tenant';
public bool $autoCreateDatabase = true;
public bool $autoMigrateTenant = true;
// Third-party package migrations run in every tenant DB
public array $tenantMigrationsNamespaces = [
'CodeIgniter\Shield\Database\Migrations',
];
The package supports three strategies: row, prefix, database. The README is explicit that row (a tenant_id column) carries "leakage risks": forgotten traits, raw SQL, joins missing the column, and IDOR. We use database here because it is the only strategy with complete isolation.
Isolation mode is set. Now we need to tell the app how to figure out which tenant a request belongs to.
Tenant identification
A recurring question is "how do I map tenants to databases without subdomains?" Subdomain-only tenancy breaks for path-based apps and API gateways. Tenantable ships five identification filters for exactly this reason:
| Filter | Identifies by | Use case |
|---|---|---|
tenant_subdomain |
URL subdomain | acme.myapp.test |
tenant_domain |
Custom domain | acme.com |
tenant_domain_or_subdomain |
Domain, then subdomain fallback | Mixed |
tenant_path |
First URL segment | /acme/dashboard |
tenant_request |
Header, query, or body field | X-Tenant: acme (APIs) |
Register your chosen filter in app/Config/Filters.php:
public array $globals = [
'before' => [
'tenant_subdomain' => ['except' => ['health', 'api/*']],
],
];
tenant_security is a separate guard, not an identification filter. Apply it on routes where you want to enforce that a tenant context exists and strip tampered tenant_id fields from input. As noted in the sharp-edges section above, this is defence-in-depth, not your primary isolation boundary.
Identification is wired. Now let's create a tenant-scoped schema.
Creating tenant migrations and models
Scaffold a tenant migration and model:
php spark tenants:make-migration CreatePostsTable --table=posts
php spark tenants:make-model Post --prefix --table=posts
Migration files land in app/Database/Migrations/Tenant/. Because we listed Shield's namespace in $tenantMigrationsNamespaces, Shield's auth tables (users, auth_identities, auth_logins, ...) will be created inside every tenant DB automatically. Same pattern as stancl/tenancy in the Laravel world.
Schema is ready. Time to bring a tenant into existence.
Creating a tenant
Create a tenant via CLI:
php spark tenants:create acme "Acme Corp"
Or programmatically:
$tenantModel->insert(['name' => 'Acme Corp', 'subdomain' => 'acme']);
The package then automatically:
Inserts the row into
tenantsRuns
CREATE DATABASE IF NOT EXISTS tenant_1Runs your tenant migrations from
$tenantMigrationsNamespaceRuns third-party migrations (Shield, etc.) from
$tenantMigrationsNamespacesFires the
tenantCreatedevent
The database name is derived at runtime from the tenant row (tenant_{id} by default, or a $databaseNameGenerator callable). It is never stored in the tenants table. Storing the secret inside the database it unlocks is a foot gun the package refuses.
Verify:
php spark tenants:list
mysql -e "SHOW DATABASES;" | grep tenant_
You should see tenant_1 in the list. But having the database is only half the story; the app needs to actually route queries to it at runtime.
Verifying the runtime swap
On each request, the filter identifies the tenant, and a DatabaseSystem bootstrapper rewrites Config\Database::$default to point at the tenant's DB, then evicts the cached connection so the next Model::find() hits the right database:
// vendor/nuelcyoung/tenantable/src/Services/TenantDatabaseManager.php
\(this->evictCachedConnection(\)group); // close + reflection-unset the singleton
\(dbConfig->{\)group} = $tenantConfig; // rewrite the default group
\(this->connections[\)group] = DbConfig::connect($group, true);
Visit http://acme.myapp.test. All models now query tenant_1.posts, not central.posts. Then you can see the swap is working.
But the database is only one stateful boundary. A multi-tenant app has several others, and they leak just as easily. Getting them wrong is more common than database leakage in part because the database swap feels like the hard part, so everything else gets less attention.
Stateful boundaries beyond the database
First, sessions. If your session driver is database and the session table lives in the central DB, sessions are shared across tenants. A user logged in on acme.myapp.test carries their session to globex.myapp.test if they switch subdomains.
Two options: store sessions in the tenant DB so they are isolated per-tenant, or namespace session cookies by tenant (session.cookie_name = sess_{tenant_id}) so each tenant's browser cookies are separate. Tenantable's SessionSystem bootstrapper handles the tenant-DB case when you are in database isolation mode. The cookie-namespacing case is on you.
Second, cache. A shared cache (Redis, file-based) with un-prefixed keys is the most common silent leakage vector I see. Tenant A caches post:42, tenant B's query resolves to the same key, and tenant B reads tenant A's data. The package's CacheSystem bootstrapper prefixes cache keys with the tenant ID automatically, but only if you use CI4's cache service. Raw apcu_fetch() or a direct Redis client bypasses the prefix. Always go through service('cache').
Third, queue workers. This is the one that bites in production. A long-running PHP worker process holds tenant state in memory between jobs; if you do not refresh the context per job, tenant A's data leaks into tenant B's job. The consensus pattern for this is "short jobs, external queue, refresh state per tenant."
Tenantable gives you the refresh hooks:
// Per-job bootstrap inside a worker loop
TenantManager::getInstance()->setTenantById($tenantId);
// ... run job ...
TenantManager::getInstance()->clear();
(new DatabaseSystem())->shutdown(); // reconnect to central DB
The honest caveat: PHP long-running workers are fragile, no matter how good the hooks are. The consensus was not "trust the process to stay clean forever." It was "enqueue per tenant, refresh per job." Tenantable package supports that model
Speaking of things that break across tenants, there is one more canonical problem worth a dedicated section.
Migration fan-out
The other canonical problem, a migration failing mid-deploy across N tenant databases, is where database-per-tenant gets scary. MySQL DDL implicitly commits (no atomic rollback across tenants, unlike Postgres), and new tenants created between releases break batch-number assumptions.
Tenantable's answer is lazy, per-tenant migration tracking. Each tenant DB keeps its own migrations table keyed by namespace, and tenants:run iterates tenants applying only pending files:
php spark tenants:run migrate # all tenants
php spark tenants:run migrate --tenants=1,3 # targeted
A fresh tenant created after a release simply runs all migrations forward from zero, so the batch-number problem disappears. What this does not solve is atomic rollback on partial failure. The package acknowledges the limit by rejecting non-MySQL drivers for auto CREATE DATABASE rather than emitting driver-invalid SQL.
We have covered provisioning, runtime swaps, stateful boundaries, and migrations. There is one lifecycle step left, and the package deliberately does not automate it.
Deprovisioning a tenant
Provisioning is half the lifecycle. The other half, deleting a tenant, is the obvious next question, and it has sharp edges that the package does not automate for you.
There is no tenants:drop command. To delete a tenant, you need to:
Drop the tenant database:
DROP DATABASE tenant_1Delete the row from the central
tenantstablePurge any cached data for that tenant (cache keys prefixed with the tenant ID)
Invalidate sessions. If sessions live in the tenant DB they go with the database. If you use the central DB for sessions, you must delete by tenant-scoped session IDs.
The package fires a tenantDeleted event when the row is removed, so you can hook cleanup logic there. The reason there is no automated DROP DATABASE is deliberate: dropping a database is irreversible, and automating it behind a model delete is a foot-gun. You do it explicitly, with a backup first.
What you built
You now have a CodeIgniter 4 application that provisions a separate MySQL database per tenant, identifies tenants by subdomain, fans migrations out to every tenant DB, swaps the database connection at runtime, isolates sessions and cache, refreshes worker context per job, and deprovisions tenants without automating irreversible operations. The sharp edges, no atomic rollback, no connection pooling, MySQL-only auto-provisioning, the fail-closed default, the middleware-as-guardrail distinction, are things you know going in rather than discovering at 2 am.
If you are heading to production next, the two things I would prioritise are a queue worker setup that refreshes tenant context per job and a deprovisioning runbook that includes a backup step before DROP DATABASE. Neither is glamorous. Both are the differences between a multi-tenant app and a multi-tenant incident.
Further reading
stancl/tenancy — the Laravel equivalent for comparison
Azure multitenant architecture guide — the isolation-spectrum framing used in the intro
