mrkindy/multi-tenant-wordpress
Composer 安装命令:
composer require mrkindy/multi-tenant-wordpress
包简介
Database-per-tenant bootstrap for WordPress and WooCommerce SaaS platforms.
README 文档
README
Database-per-tenant bootstrap for WordPress and WooCommerce SaaS platforms.
One WordPress codebase resolves the request host, reads tenant routing from an
independent control database, retrieves the database password from a secret
provider, and defines WordPress database constants before wpdb is created.
The package does not require WordPress Multisite, Bedrock, Laravel, or another framework. It supports PHP 8.3+, WordPress Core, Bedrock, Docker, FrankenPHP, Nginx, and Apache.
Why not WordPress Multisite?
While WordPress Multisite is a built-in feature, it often falls short for SaaS platforms due to its shared database architecture. This package offers several advantages over Multisite:
- Strict Data Isolation: Each tenant has its own dedicated database, preventing data leakage and making per-tenant backups or migrations trivial.
- Enhanced Security: Every tenant can use unique database credentials. In Multisite, a single compromised database credential grants access to the entire network.
- Infrastructure Scalability: Databases can be spread across different database servers or clusters easily. Multisite typically requires complex sharding plugins to achieve this.
- Plugin Compatibility: Many WordPress plugins are not "Multisite-aware" and behave unexpectedly in a shared environment. By keeping each site as a standalone instance, you ensure maximum compatibility.
Installation
composer require mrkindy/multi-tenant-wordpress
Required PHP extensions are PDO, JSON, and Sodium. The control database and
tenant databases require separate credentials. The control database account
should have read-only access to the tenants table.
Request Lifecycle
Bootstrap::boot() performs these operations before WordPress loads:
- Reads and normalizes
$_SERVER['HTTP_HOST']. - Rejects empty, malformed, IP, untrusted, and disallowed localhost hosts.
- Resolves the tenant through the cache and PDO control repository.
- Rejects any tenant whose status is not
active. - retrieves the tenant password through the configured secret provider.
- Defines
DB_NAME,DB_USER,DB_PASSWORD, andDB_HOST.
Host validation intentionally happens before the control-database query.
Configuration
use MrKindy\MultiTenantWordPress\Bootstrap\Bootstrap; use MrKindy\MultiTenantWordPress\Config\Config; Bootstrap::boot(new Config( controlDatabaseHost: getenv('CONTROL_DB_HOST') ?: 'control-db', controlDatabasePort: (int) (getenv('CONTROL_DB_PORT') ?: 3306), controlDatabaseName: getenv('CONTROL_DB_NAME') ?: 'wordpress_control', controlDatabaseUser: getenv('CONTROL_DB_USER') ?: 'wordpress_control', controlDatabasePassword: getenv('CONTROL_DB_PASSWORD') ?: '', encryptionKey: getenv('TENANT_ENCRYPTION_KEY') ?: '', secretProvider: Config::SECRET_PROVIDER_ENV, cacheProvider: Config::CACHE_PROVIDER_ARRAY, trustedDomainSuffixes: ['*.example.com', '*.mrkindy.com'], allowLocalhost: false, cacheTtlSeconds: 60, ));
trustedDomainSuffixes accepts wildcard suffixes and literal suffixes.
*.example.com matches subdomains but not example.com; example.com matches
the apex and its subdomains. An empty list allows any syntactically valid
hostname. Production deployments should always provide an allowlist.
Custom implementations can be injected with tenantRepository,
customSecretProvider, and customCache. The bundled array cache is
request-local under traditional PHP and process-local under long-running
servers. It encrypts cached tenant payloads with encryptionKey, which must be
a base64-encoded 32-byte Sodium key. Use a bounded external Redis or Memcached
implementation in a multi-node deployment.
Control Database Schema
Apply config/control-database.sql to a database that is separate from all WordPress tenant databases.
The encrypted_database_password column stores an opaque reference, never a
plaintext password:
- With
EnvSecretsProvider, store an environment variable name such asTENANT_42_DATABASE_PASSWORD. - With
AwsSecretsProvider, store an AWS Secrets Manager secret name or ARN.
Normalize domains to lowercase without ports or trailing dots before insert. Each tenant database user should have access only to its own database.
Example record:
INSERT INTO tenants ( domain, database_host, database_port, database_name, database_user, encrypted_database_password, status, plan, metadata ) VALUES ( 'shop.example.com', 'tenant-db-42.internal', 3306, 'tenant_42', 'tenant_42_user', 'TENANT_42_DATABASE_PASSWORD', 'active', 'business', '{"uploads_path":"/srv/uploads/tenant-42"}' );
metadata.uploads_path is reserved for future uploads isolation support. This
release isolates databases and configuration; it does not rewrite WordPress
upload paths.
WordPress Core Integration
Require Composer and boot the package in wp-config.php before this line:
require_once ABSPATH . 'wp-settings.php';
Do not define the four database constants before bootstrapping. See
examples/wordpress-core.php for generic HTTP
error handling. The bootstrap returns the resolved immutable Tenant DTO when
application code needs tenant metadata.
Bedrock Integration
Place the bootstrap near the top of config/application.php, after Composer
autoloading and environment loading, but before Roots\Config::apply().
Remove Bedrock's normal DB_NAME, DB_USER, DB_PASSWORD, and DB_HOST
definitions. See examples/bedrock.php.
Bedrock is supported as an integration target, not required as a dependency.
Docker Integration
Pass only control-plane credentials and secret-provider configuration to the WordPress container. Do not inject every tenant password into a shared image.
services: wordpress: environment: CONTROL_DB_HOST: control-db CONTROL_DB_PORT: 3306 CONTROL_DB_NAME: wordpress_control CONTROL_DB_USER: wordpress_control_reader CONTROL_DB_PASSWORD_FILE: /run/secrets/control_db_password TENANT_ENCRYPTION_KEY_FILE: /run/secrets/tenant_encryption_key TENANT_SECRET_PROVIDER: aws TRUSTED_DOMAIN_SUFFIXES: "*.example.com" AWS_REGION: us-east-1
Docker secrets exposed as files should be read by the application's
configuration layer and passed to Config. See examples/docker.php.
The same early-bootstrap rule applies to FrankenPHP, Nginx/PHP-FPM, and Apache.
AWS Secrets Manager
Set secretProvider to Config::SECRET_PROVIDER_AWS. AWS credentials are
resolved by the AWS SDK default credential chain, so IAM roles for EC2, ECS, or
EKS are preferred over static access keys.
$config = new Config( // Control database settings... encryptionKey: getenv('TENANT_ENCRYPTION_KEY') ?: '', secretProvider: Config::SECRET_PROVIDER_AWS, awsRegion: 'eu-central-1', awsSecretPasswordKey: 'password', );
The AWS secret may be a raw password or a JSON object:
{"password":"tenant-database-password"}
Grant the runtime identity secretsmanager:GetSecretValue only for tenant
secret ARNs it needs. Secret values are held in memory only long enough to
configure WordPress.
Local Encryption
EncryptionService provides authenticated Sodium Secretbox encryption for
control-plane tooling. Create it once with the configured key, then reuse that
instance for every encryption and decryption operation:
$key = EncryptionService::generateKey(); $encryption = new EncryptionService($key); $ciphertext = $encryption->encrypt('secret'); $plaintext = $encryption->decrypt($ciphertext);
Keys are base64 encoded and must be stored outside the control database. In
production, generate the key once and pass it through Config::$encryptionKey
from an environment variable or secret manager; do not generate a new key per
request.
Secret-provider references remain the recommended runtime model.
Security Model
- Host headers are validated before database or secret access.
- IP addresses, malformed ports, control characters, URL syntax, and direct localhost access are rejected by default.
- Tenant lookup uses a prepared PDO statement and never uses WordPress
wpdb. - Tenant database users should be unique and least-privileged.
- Passwords are retrieved through
SecretProviderInterface. - Expected request failures expose stable generic messages.
- Unexpected exceptions are logged through PSR-3 and wrapped in a generic
ConfigurationException. - Database constants are defined only when absent; pre-existing constants are not overwritten.
Behind a reverse proxy, configure the proxy to replace the incoming Host
header and allow only expected virtual hosts. This package deliberately does
not trust X-Forwarded-Host.
Logging and Error Handling
Pass any PSR-3 logger through Config::$logger. Without one, NullLogger is
used. At the web boundary, catch package exceptions and return generic pages:
try { Bootstrap::boot($config); } catch (InvalidDomainException | TenantNotFoundException) { http_response_code(404); exit('Site not found.'); } catch (TenantSuspendedException) { http_response_code(403); exit('Site unavailable.'); } catch (Throwable) { http_response_code(503); exit('Service temporarily unavailable.'); }
Do not render exception traces or control-database errors to clients.
Testing and Quality
composer install composer check vendor/bin/phpunit --coverage-text
CI tests PHP 8.3 and 8.4, runs PHPStan level 9, enforces PSR-12, and fails below 90% statement coverage.
Troubleshooting
WordPress connects to the old database
The package ran after database constants were defined or after
wp-settings.php. Move Bootstrap::boot() earlier and remove old constants.
Every request returns an invalid-host error
Check the proxy-preserved Host value and trustedDomainSuffixes. Wildcard
entries do not match the apex domain.
Tenant not found
Store the normalized lowercase domain without a port or trailing dot. Confirm
the control database user can select from tenants.
Secret unavailable
For environment secrets, the reference must be an uppercase variable name. For AWS, verify region, secret ID/ARN, IAM permissions, and the configured JSON password key.
Long-running server serves the wrong tenant
Do not define database constants once and then reuse the same PHP worker for different hosts. WordPress database constants are process-global and cannot be changed. FrankenPHP worker mode or other persistent runtimes must isolate one tenant per worker/process or use non-worker request execution.
统计信息
- 总下载量: 0
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 3
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-06-16