Customizing — code-owned reference catalogs
The controlled vocabularies your app switches on — partner roles, relationship types, dropdown values, document types, tax codes — declared in code and projected into the database by ui5:sync. Declare them once; every install and every new tenant gets them, with nothing to remember and nothing to seed by hand.
In one sentence. A Customizing catalog is a flat, code-owned table whose every row is declared by an attribute;
ui5:syncreconciles the table to exactly what the code declares, and labels resolve from i18n while the source-language text stays in the column as a fallback.
Customizing vs Tailoring
Two kinds of data look similar and are often confused. The SDK keeps them strictly apart — they never share a table.
| Customizing | Tailoring | |
|---|---|---|
| What | The vocabulary — the list of role codes, relationship types, … | The values — who holds which role, the actual assignments |
| Owner | Your code (an attribute) | The running system / a consultant |
| Travels by | the deployment (ui5:sync) | runtime writes (assignment tables) |
| Example table | sdk_partner_roles | sdk_partner_role_assignments |
This page is about Customizing — the system offers the vocabulary; tailoring fits it to a tenant. The rule that makes it safe: a Customizing table is owned entirely by code — ui5:sync will delete any row you didn't declare — so tailoring data can never live there, and you never hand-seed a catalog.
Declaring a catalog
A catalog entry is a PHP attribute that fulfils CustomizingEntry. The contract splits the catalog shape (the static methods — the same for every row) from the row (the instance):
namespace App\Customizing;
use Attribute;
use BackedEnum;
use LaravelUi5\Sdk\Customizing\Contracts\CustomizingEntry;
#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
final class DocumentType implements CustomizingEntry
{
public function __construct(
public BackedEnum|string $code,
public string $name,
public ?string $description = null,
public ?string $helpUuid = null,
) {}
public static function catalog(): string { return 'document_types'; }
public static function table(): string { return 'app_document_types'; }
public static function identityColumn(): string { return 'code'; }
public static function translatable(): array { return ['name', 'description']; }
public function identity(): string
{
return $this->code instanceof BackedEnum ? (string) $this->code->value : $this->code;
}
public function columns(): array
{
return ['name' => $this->name, 'description' => $this->description];
}
public function helpUuid(): ?string
{
return $this->helpUuid;
}
}Then declare the entries on a module — repeatable, one per row:
#[DocumentType('invoice', 'Invoice', 'A billable document.')]
#[DocumentType('credit_note', 'Credit Note')]
#[DocumentType('quote', 'Quote', 'A non-binding offer.')]
class BillingModule extends AbstractUi5Module { /* … */ }That is the whole authoring surface. The SDK discovers every CustomizingEntry attribute across your modules in one pass and projects each catalog into its table on ui5:sync. You provide the table (a migration); the SDK fills and maintains the rows.
helpUuid() is part of the contract: return the UUID of the row's help document, or null when the row has none. It is not a columns() member. If your table has the optional help_uuid column, ui5:sync writes the value there, and ui5:help checks that every non-null UUID names a help document of the declaring module. Without the column, the value is ignored.
Codes: enum or string
code is BackedEnum|string. Reach for a backed enum — you get IDE navigation, refactor-safety, and declarations that contribute types rather than bare strings; identity() dissolves the enum to its scalar value ('invoice'). The string arm is there for a one-off code that doesn't justify an enum case.
How it syncs
ui5:sync reconciles each catalog table to exactly what the code declares:
- Insert rows that are newly declared.
- Update rows whose columns changed.
- Delete rows that are no longer declared.
It is idempotent — run it as often as you like; it converges and never duplicates. ui5:sync --dry previews the plan and changes nothing — a cross-environment drift diff a seeder could never give you.
php artisan migrate # the table
php artisan ui5:sync # fill + maintain the rowsCode is the owner
Because the worker deletes undeclared rows, the catalog is exclusively code-owned — that is the guarantee that lets you trust it. Two consequences worth knowing:
- Retiring an in-use code fails loud. If you remove a declaration while runtime data (an assignment) still references it, the delete is blocked by a foreign key (
ON DELETE RESTRICT) and the sync stops with a clear error. Clear the references first, then remove the code. Stale vocabulary never lingers; live vocabulary is never pulled out from under its data. - Multiple modules can contribute to one catalog. The SDK declares a generic baseline; your app adds its own codes to the same table. Both are code, both are reconciled — they just need distinct codes.
Labels & i18n
Human-readable columns (the ones you list in translatable()) are stored as their source-language literal — so they're queryable, sortable, and always available as a fallback. The displayed label resolves from i18n by a fixed key convention:
customizing.<catalog>.<code>.<column>e.g. customizing.document_types.invoice.name. Don't hand-write these keys — the SDK generates them:
php artisan ui5:i18nui5:i18n walks the synced catalogs and writes one key per translatable cell into a base bundle, seeded with the literal. It is:
- Base-only — it writes the base
.propertiesand never a localized*_<locale>.properties. Those belong to your translators. - Append-only — it adds missing keys and never overwrites an existing one, so it is safe to re-run and won't trample a label a translator refined.
By default it writes resources/ui5/i18n/db.properties — a dedicated, refresh-safe bundle, kept separate from your app's own i18n.properties so ui5:app --refresh can't clobber the generated keys. Use --out to target a different file. Enhance your app's i18n model with db.properties alongside i18n.properties, and translators localize db_<locale>.properties.
What ships with the SDK
The SDK declares its own partner-domain baseline as Customizing, so it lands in every install via ui5:sync — no seeding required:
- Relationship types (
sdk_partner_relationship_types) —employed_by,owns,shareholder_of,member_of,successor_of,affiliate_of. The relationship graph is SDK-owned because the SDK consumesemployed_byin code (it is the org-scoping key).
Partner roles are not shipped by the SDK. The sdk_partner_roles table, the #[PartnerRole] attribute, and the PartnerRoleScope enum are all part of the SDK, but the catalog of codes is yours: the SDK consumes no partner-role code in its own logic, so you declare the roles your application actually uses — supplier, bill_to, your funnel's lead / prospect / customer, whatever — on your own module, using the same #[PartnerRole] attribute and the same table:
#[PartnerRole('bill_to', PartnerRoleScope::Functional, 'Bill-To Contact', 'sap-icon://address-book', 'Invoice destination.')]
#[PartnerRole('lead', PartnerRoleScope::Structural, 'Lead', 'sap-icon://target-group', 'Registered, no active license yet.')]
class YourModule extends AbstractUi5Module { /* … */ }Contributing from outside a module
Every example above hangs the attribute on a module, and that is deliberate: the SDK discovers CustomizingEntry attributes by reflecting over the registered modules — the ones in config('ui5.modules') plus the infrastructure modules packages register, the SDK's own included — wherever their classes live. It does not scan the filesystem, so an attribute outside a registered module class is never found. There is no "drop the attribute anywhere and we'll find it."
That boundary is a safety property, not a limitation. Because ui5:sync deletes any catalog row the declared set doesn't surface (see Code is the owner), the declared set has to be complete in every environment where you sync. Your module list is — it's the same configuration in dev, CI, and production. A filesystem scan's result is not: it depends on which files happen to be present and autoloadable wherever the sync runs, and a miss wouldn't error — it would silently delete the rows it failed to see. Reflecting over the module list keeps "what's declared" complete by construction.
So what about a catalog row that names something which isn't naturally a module — the classic case being a pluggable provider you bind in a service provider (an address-lookup adapter, say)? Don't reach for the provider class. Ask whether the capability deserves to be a library:
php artisan ui5:libui5:lib scaffolds a small library and its module container. Put the CustomizingEntry attribute on the generated module — not on the library class; the discovery pass reflects modules, never library artifacts, so an attribute on the library is silently ignored. Then register the module the ordinary way (add it to config('ui5.modules'), or via Ui5InfrastructureCollector if it's infrastructure). The provider implementation stays where it belongs — bound in your service provider; the catalog row that names it lives on the library's module. Declaration and wiring are two different jobs, and keeping them apart is the point.
If you're tempted to skip the library and scatter the attribute through app/, treat that as a signal: you've got a reusable capability — a provider your team will lift into the next project, maybe its own package one day — wearing prototype clothes. The cost of giving it a proper home is one command. The cost of teaching the engine to chase it through your application namespace is a permanent dependency on filesystem completeness, under a delete-what-it-can't-see invariant. Promote the capability.
Rules of the road
- Flat catalogs only. One table, one identity column, scalar columns. A catalog that needs a join or a pivot is not a fit for this engine (the SDK's own role↔ability model, for instance, has its own machinery).
- Catalogs are independent. No catalog references another; sync order doesn't matter.
- The table is yours; the rows are the SDK's. Ship the migration that creates the table (with the identity column unique);
ui5:syncowns its contents thereafter. ui5:syncis mandatory. A catalog with no sync run is an empty table. It's a standard step in every deployment, right aftermigrate.