Skip to content

Profiles

Daniel Carbone edited this page Oct 20, 2026 · 1 revision

Profiles

php-fhir can generate classes for FHIR profiles (for example US Core) as an extra step after the normal schema-based generation. The XSD schemas do not describe profiles, so they are read from FHIR implementation guide packages (package.tgz files or extracted package directories) containing StructureDefinition JSON.

What is generated

Profile classes are placed in a Profiles namespace (configurable) beneath each version's namespace, for example <Prefix>\Versions\R4\Profiles\USCore\FHIRResource\FHIRDomainResource\USCorePatientProfile. Beneath its package's namespace, a profile class sits at the same relative path as the type it constrains has beneath Types (here Types\FHIRResource\FHIRDomainResource\FHIRPatient), and the directory structure follows. Package-wide classes such as PackageMap and PackageAutoloader live directly in the package namespace. Each class:

  • extends the type it constrains (FHIRPatient), so it can be used anywhere the base type is accepted. A profile derived from another generated profile extends that profile instead.
  • is named from the StructureDefinition name, converted to PascalCase. Name collisions fail generation.
  • exposes PROFILE_URL, PROFILE_VERSION, PROFILE_NAME, PROFILE_BASE_TYPE and PROFILE_INVARIANTS constants.
  • is validated through the type's existing _getValidationErrors(), which also reports the profile's own failures.
  • implements the core Types\ProfileTypeInterface (_getProfileUrl(), _getProfileVersion() and _getProfileValidationErrors(), which runs only the rules the profile and its parent profiles add).
  • has typed accessors for top-level slices, named get<SliceName><Element>(), e.g. getRaceExtension(). They return a single object when the slice's maximum is 1, otherwise an array.

Alongside the classes, each version gets a ProfileMap and ProfileAutoloader. Each package also gets its own PackageMap (canonical url to class) and PackageAutoloader inside its namespace, implementing the shared PackageMapInterface and PackageAutoloaderInterface from the root Profiles namespace. The root ProfileMap only lists the package maps and queries them in turn, and ProfileAutoloader loads and registers a package's autoloader the first time one of that package's classes is requested, so nothing is loaded for packages you do not use. Package namespaces may be nested (e.g. Vendor and Vendor\IG); every package whose namespace a requested class falls under is tried until one loads it.

Configuration

Add profiles to a version:

[
    'name' => 'R4',
    'schemaPath' => __DIR__ . '/input/R4',
    'profiles' => [
        'namespace' => 'Profiles',
        'packages' => [
            [
                'name' => 'hl7.fhir.us.core',                       // required, must match the package's own name
                'source' => __DIR__ . '/input/hl7.fhir.us.core.tgz', // required, .tgz or extracted directory
                'namespace' => 'USCore',                             // default derived from name
                'dependencies' => ['hl7.fhir.uv.sdc'],               // names of other configured packages, see below
                'strict' => true,                                    // fail instead of warn+skip on unresolvable content
            ],
        ],
    ],
],

Package dependencies

dependencies lists the names of other packages configured in the same version's packages. An unknown name or a circular dependency is a configuration error. Dependencies are built first, and the dependent package then:

  • resolves base profiles, extensions and ValueSets through them (including their dependencies);
  • can have its classes extend the dependency's classes.

A package's strict setting applies to its own profiles only; a dependency is checked, and its warnings logged, under its own setting.

Every profile in each configured package is generated. When using --profilePackages, the requested packages' dependencies are written too, as their classes are extended.

Only local files are supported; nothing is downloaded. A package must target the FHIR version it is configured under (a mismatch fails with a clear error), so R4, R4B and R5 each need their own IG builds. Packages are generated by default along with the library whenever they are configured; see CLI Usage for --profiles, --noProfiles, --onlyProfiles and --profilePackages.

Using profile classes

Register the profile autoloader (the main library Autoloader does not load profile classes). Its unregister() also unregisters the package autoloaders it registered:

\Acme\Fhir\Versions\R4\Profiles\ProfileAutoloader::register();

$patient = \Acme\Fhir\Versions\R4\Profiles\USCore\FHIRResource\FHIRDomainResource\USCorePatientProfile::jsonUnserialize($decoded);
foreach ($patient->_getValidationErrors() as $path => $rules) {
    foreach ($rules as $rule => $message) {
        echo "{$path} [{$rule}]: {$message}\n"; // e.g. "identifier [min_occurs]: Field ... must have at least 1 elements"
    }
}
$race = $patient->getRaceExtension();

Automatic resolution while parsing

Set profileResolution to auto (see Configuration Reference) and ResourceParser will pick the profile class from meta.profile:

$version = new \Acme\Fhir\Versions\R4\Version([
    'unserializeConfig' => ['profileResolution' => 'auto', 'onProfileViolation' => 'fallback'],
]);
$resource = \Acme\Fhir\Encoding\ResourceParser::parse($version, $json); // USCorePatientProfile or FHIRPatient

onProfileViolation acts on _getProfileValidationErrors() only, so a resource that conforms to the profile is not rejected for problems in the base type or in nested elements the profile does not constrain.

When several declared profiles apply, the most specific one (the one refining all the others) wins. meta.profile is written back unchanged on serialization. If ProfileAutoloader is not registered, resolution silently falls back to the base types.

What is validated

Profiles use the library's regular validation system. Each profile class carries a generated rule map (_PROFILE_VALIDATION_RULES) that _getCombinedValidationRules() merges into the base type's rules, so _getValidationErrors() is not overridden and the usual field => [rule => message] map is returned. Rule keys are field names, or dotted paths for nested elements (name.family, contact.relationship, deceased[x]). Errors are keyed by the concrete path, e.g. name.0.family or contact.1.relationship, using the standard rule names (min_occurs, max_occurs, fixed_value, pattern_value, code_binding, choice_occurs, slice_occurs). _getProfileValidationErrors() returns the same map restricted to the rules set by the profile classes, without descending into the elements' own validation.

A profile may only tighten the base type: the merged minimum is the larger of the two, the merged maximum the smaller, and any other rule declared by the profile replaces the base type's. A profile that loosens cardinality is silently ignored in favour of the base type. min_occurs / max_occurs on a nested path apply per parent instance; a maximum of 0 means the element must be absent.

It checks:

  • cardinality (min / max, including prohibited elements), per parent instance
  • narrowed choice types (value[x])
  • fixed and pattern values, on every occurrence of a repeating element. A fixed value must match exactly: a complex value may not have additional fields or list entries, and lists must be in the same order. A primitive is compared on its value; its id and extensions are not part of the fixed value. Numbers are compared by value, so 1.50, 1.5 and 15e-1 are equal. A pattern is a subset match: every field it names must match, and each entry of a list in the pattern must match some entry of the instance's list.
  • required bindings, when the ValueSet can be fully enumerated from the configured packages
  • slice cardinality. With value / pattern discriminators, a slice's members are matched on the fixed / pattern values at the discriminator paths (or on an element above them), and on the extension profile url for a url discriminator. Without discriminators, they are matched on all of the slice's fixed / pattern values and its extension profile url.

Rules that cannot be resolved against the generated types are skipped, and counted in the generation log. Fixed, pattern and binding constraints on a [x] choice element are skipped too.

Memory usage

Profile packages are large, and generation is the most memory hungry part of php-fhir. Every package a profile package depends on is loaded and parsed along with it, and the terminology, extensions and cross-version packages are huge.

As a reference, generating US Core for R4 requires nine packages (US Core, SDC, SMART App Launch, PHINVADS, HL7 Terminology, HL7 Extensions, the R5-to-R4 cross-version package, HL7 UV Tools and the R4 core package) and peaks at roughly 550 MB. Allow PHP at least 1 GB:

php -d memory_limit=1G bin/generate.php --onlyProfiles --versions R4

To keep this down, packages are processed one at a time in dependency order, only StructureDefinition, ValueSet and CodeSystem files are read (recognised by their conventional name, the package's .index.json, or their content), each package is loaded once however many packages depend on it, narrative text is dropped, resources are held compressed, and everything no remaining package depends on is released as soon as its classes are written. Generating several versions in a single run is sequential, but add headroom if you do.

Generated classes use the classes they reference rather than writing fully qualified names inline.

Limitations

  • Invariants are metadata only. FHIRPath constraints are exposed in PROFILE_INVARIANTS but never evaluated.
  • Required bindings to ValueSets that cannot be enumerated locally (filters, other packages, incomplete code systems, paged or truncated expansions without a compose definition) are skipped and logged at info, as are ValueSets that enumerate to no codes at all. An expansion is only trusted when it starts at offset 0, is not marked toocostly or unclosed, and the distinct codes it lists account for its total (required when a count parameter was used). Base-specification bindings are mostly enforced by the base types' own enums.
  • Slices that cannot be matched, other discriminator types (type, profile, exists), discriminator paths using functions such as resolve() or extension(), constraints inside slices, and closed slicing are not supported. Skipped rules are logged at info.
  • There are no typed value[x] accessors for extension profiles.

Clone this wiki locally