Skip to content
DedicatedPHP Contact

Per-tenant configuration in a PHP SaaS without creating unmanageable variants

Design governed per-tenant configuration in a PHP SaaS to meet B2B needs without multiplying forks or conditionals.

Editorial diagram of a PHP SaaS with centralized per-tenant configuration, permissions, capabilities, and asynchronous processes

A commercial request becomes dangerous when it stops being an explicit product decision and starts materializing as an if ($tenantId === ...). At first, it solves an urgent need. Over time, that condition appears in controllers, templates, queue processes, exports, and APIs. The result is not configuration: it is implicit product variants that are difficult to test, explain, and retire.

Per-tenant configuration in a PHP SaaS must enable deliberate, governed differences, rather than preserve every historical exception. The useful question is not “can we do this for this customer?”, but “does this variation represent a stable product dimension that other customers might need, with sustainable rules and support?”

The warning sign: a permanent code exception

The warning sign: a permanent code exception — DedicatedPHP visual guide

There is a difference between adapting an experience and maintaining a hidden product branch. It is worth intervening before a particular request creates any of these signals:

  • The tenant, domain, or customer identifier appears in business logic.
  • The same rule is replicated in the interface, API, and asynchronous worker.
  • The team cannot answer which customers have an exception or who approved it.
  • A plan change alters functional behavior without a central definition.
  • Removing an adaptation requires searching for conditionals across multiple repositories or services.

An exception may be legitimate during discovery or a migration, but it must have an owner, a review date, and an outcome: turn it into a product capability, isolate it as a specific integration, or reject it. Leaving it unclassified turns technical debt into an undocumented commercial promise.

Do not confuse configuration, permissions, capabilities, and custom development

These mechanisms answer different questions. Mixing them produces opaque designs and contradictory rules.

  • Configuration: defines how an existing feature behaves for a tenant. For example, the format of a numbering sequence, the default language, or whether a workflow requires an additional approval.
  • Permissions: determine what an identity can do within a tenant. A user may have permission to approve payments even if approval is configured as mandatory.
  • Capabilities: indicate whether the tenant has access to a feature or an operational limit. They may depend on a contract, plan, or controlled rollout, but they should not contain all domain logic.
  • Custom development: covers behavior that does not fit into a reusable product dimension, such as an integration with the customer’s own system or a unique contractual transformation.

A practical rule helps make the decision: if it changes who performs an action, use permissions; if it changes whether a feature exists or is available, use capabilities; if it changes how an available feature operates, use configuration. If it changes the business model exclusively, do not disguise it as a flag.

What should be configurable and what should remain in the core

An option deserves to enter the configuration catalog when it has clear semantics, a finite set of values, known validations, and a reasonable expectation of reuse. It also needs an understandable support experience: someone must be able to explain the effect of changing it without inspecting code.

Presentation parameters, notification policies, thresholds, approval sequences, regional preferences, and choices between already supported workflows are often good candidates. In contrast, security invariants, data integrity, core financial calculations, and rules whose change would require reinterpreting existing entities or contracts should remain in the core.

Do not turn arbitrary data into configuration solely for flexibility. A JSON field without a schema can hide dependencies that are impossible to discover. When an option changes a critical rule, define types, allowed values, conditions of use, and consequences for previous data.

Build a governed configuration model

An isolated key is not enough. Each catalog definition must include metadata that makes it possible to operate the product safely:

  • Key and functional description: stable names oriented toward the domain rather than implementation details.
  • Owner: the team or person responsible for deciding its evolution and removal.
  • Scope: global, tenant, organizational unit, project, or user. Avoid allowing every scope by default.
  • Default value: explicit behavior when no override exists.
  • Type and validation: boolean, enumeration, number with a range, or a structure validated through a schema.
  • Dependencies: requirements related to other options, capabilities, or migration status.
  • Sensitivity: data classification and access rules for reading and modification.
  • Lifecycle: introduction, review, deprecation, and planned removal dates where applicable.

In PHP, centralize resolution in a domain service, for example TenantSettings, and provide typed objects instead of arrays without a contract. The application can combine the global value, tenant value, and a more specific value through documented precedence. The absence of a value must always resolve to the default value, not to a different interpretation in each consumer.

$policy = $tenantSettings->approvalPolicy($tenantId);
if ($policy->requiresSecondApproval()) {
    $workflow->requestSecondApproval($order);
}

Storage can be relational or document-based, but the catalog and validation should not depend on the persistence format. Also maintain an immutable history of changes: previous and new value, actor, time, reason, and modification channel. The history does not replace an audit log of business actions, but it makes it possible to reconstruct which configuration was in effect.

Evaluate the decision at the appropriate boundary

The problem of scattered conditionals is not solved by moving them all into a controller. Configuration that affects a business rule must be evaluated in the domain service or policy that applies that rule. The controller translates the request; the template presents the result; neither should independently decide a tenant policy.

For complex behaviors, use registered strategies or policies instead of chains of booleans. A billing policy can select an implementation among supported modes after validating that the tenant has the required capability. This way, the interface, API, and queue invoke the same decision.

Templates can receive an already prepared view, including capability indicators to show or hide actions. Hiding a button is not authorization. The API must apply permissions, capability, and configuration on the server, even if the interface does not expose the operation.

Capabilities and limits without making plans rigid

A commercial plan can grant capabilities, but it must not become a collection of if ($plan === '...'). Model a stable capability, such as advanced_approvals or api_access, and resolve which tenants have it through a contractual or administrative source. Then, functional logic queries the capability, not the plan name.

Limits require an even more precise definition: what is counted, in which time window, when blocking is applied, and how retries and queue processes behave. A limit must be observable and consistent across all entry points. If an integration creates resources outside the main interface, it cannot bypass the same control.

Change settings safely and reversibly

Changing an option can have immediate effects on work in progress, existing records, or integrations. Before saving, validate the type, administrative permissions, dependencies, and compatibility with the current state. When the impact is relevant, offer a preview of the change: which workflow will be activated, which constraints it violates, and which future operations it will affect.

A gradual rollout is different from exposing an option throughout the interface. You can enable a capability for a controlled set of tenants and observe its behavior before exposing it generally. Also define a rollback: which value restores the previous state, whether associated data migrations exist, and what happens to operations started under the new configuration.

A reversible interface change may not be reversible in the data. Treat both dimensions separately before activating a new policy.

Maintain consistency across queues, APIs, and integrations

Asynchronous processes introduce an additional decision: resolve configuration when the job runs or retain a snapshot when it is created. For actions that must respect the current policy, resolve it at execution time and include the tenant in the job context. For documents, calculations, or communications that must reproduce the original decision, store an explicit version or snapshot alongside the command.

Do not mix both options without declaring it. A retry can change its outcome if it queries updated configuration. Define idempotency, configuration version, and expected retry behavior. External integrations need equivalent contracts: prior validation, error handling, limits, and traceability by tenant, without sending secrets or personal data to diagnostic logs.

Audit and support: explain the observed behavior

Audit and support: explain the observed behavior — DedicatedPHP visual guide

Support needs to answer why a customer sees a workflow, not only what value a key has. Record a decision trace with the tenant identifier, definition version, source of the effective value—default or override—relevant capabilities, and evaluation result. Restrict access to that information and mask sensitive values.

Complement this traceability with usage metrics by option, validation errors, failed changes, and unused options. Configuration with no use may be obsolete; configuration used by only one tenant for a long time deserves product review. The goal is not to eliminate every difference, but to make every difference explicit, verifiable, observable, and removed when it no longer provides value.

Want to apply these ideas to your project?Let’s discuss your PHP platform.
View related service