Per-tenant settings (tenant.settings). Reads/writes the central
tenant_settings_* tables, keyed by tenant_id, exposing
SettingsServiceContract and a JSON API under /api/settings/*.
The acting tenant always comes from the authenticated Identity (the signed
tnt claim) — never from client input. An unscoped (central) token is rejected
with 403.
GET /api/settings/{section}returns the tenant's stored row, or a fully populated DTO of hard-coded defaults (DTO::defaults()) when none exists — callers never see nulls for defaulted columns.PUT /api/settings/{section}is a partial update: the payload is validated, then merged over the tenant's current stored settings, so an omitted field keeps its existing value (it is never reset to a default). Input is validated viaPlugins\Validation\Validator→422with field errors.- Writes are authorized: the caller needs the
settings:managepermission or anadmin/superrole, elseServiceException(unauthorized). Reads are open to any authenticated tenant member.
| Section | Table | DTO |
|---|---|---|
company |
tenant_settings_company |
CompanySettingsDTO |
contact |
tenant_settings_contact |
ContactSettingsDTO |
email |
tenant_settings_email |
EmailSettingsDTO |
email_providers |
tenant_settings_email_providers |
EmailProviderSettingsDTO |
system |
tenant_settings_system |
SystemSettingsDTO |
Domain/ValueObjects/SettingsSection is the single source of truth for the
section→table mapping; no caller-supplied string is ever interpolated into SQL.
The company logo is a stored blob, not a path the client controls. Two routes
(both requires: ["storage.local"]) manage it via StoragePort:
POST /api/settings/company/logo— multipart fieldcompany_logo. Validates the extension (png, jpg, jpeg, gif, webp, svg, ico), stores the blob undertenants/{tenantId}/branding/with a random name, persists its path ontocompany_logo, deletes the previous blob, and returns the settings plus a signedlogo_url. A failed authorization/persist deletes the just-stored blob (no orphans).DELETE /api/settings/company/logo— clearscompany_logoand deletes the stored blob.
Unlike the legacy controller, nothing is written to public_path() and there is
no favicon-set side effect — a single blob per tenant, served via a signed URL.
The schema lives in database/migrations/ and is owned by this plugin. These are
central control-plane tables (char(31) tenant_id PK) with a foreign key to the
Tenancy tenants table, so they run on the central connection and require
Tenancy's tenants table to exist first (earlier migration timestamps guarantee
that ordering).
This framework does not declare migration directories in module.json.
Register this plugin's path in the project's config/let-migrate.php paths[]:
'paths' => [
$root . '/database/migrations',
$root . '/plugins/Tenancy/database/migrations', // tenants (FK target) — first
$root . '/plugins/Settings/database/migrations',
],Then php cli/run.php migrate:run applies them in timestamp order across all paths.
Register the provider in the project bootstrap, then opt routes in on demand:
->withModules([ /* … */ \Plugins\Settings\Provider::class ])A route uses it via "requires": ["tenant.settings"].