A Module is a named group of XlsForm survey questions (e.g. diet_diversity, local_context). A Module Version is one concrete set of those questions — the "Global" default plus any country-specific or team-specific alternatives. The selected_xlsform_module_versions pivot stitches chosen versions together into a deployable Xlsform.
| Table | Purpose |
|---|---|
xlsform_module_versions |
One record per version of a module |
xlsform_module_version_locale |
Which locales/languages this version has translations for |
selected_xlsform_module_versions |
Which version each Team's Xlsform uses, with an order column |
xlsform_module_versions columns:
xlsform_module_id— parent module (cascade delete)owner_id→teamstable (null on team delete) — for team-owned custom versionsname— human-readable label (unique per module)is_default— marks the "Global" version auto-created when a module is bornprocessing— lock flag set during async import pipelinecountry_id— added January 2025 (⚠️ no FK constraint in the migration — may be intentional for flexibility, or an oversight)
XlsformTemplate
└── XlsformModule (has can_be_extended / can_be_replaced / row_names / default_order)
└── XlsformModuleVersion [implements HasMedia]
├── SurveyRow (ordered by row_number)
├── ChoiceList
│ └── ChoiceListEntry
├── Locale (many:many via xlsform_module_version_locale pivot)
└── Xlsform (many:many via selected_xlsform_module_versions pivot)
XlsformTemplate.locales is a computed intersection: only locales present in every default module version appear as available on the template.
XlsformModule.php:36-44 — the booted() hook fires firstOrCreate every time a module is created:
$module->xlsformModuleVersions()->firstOrCreate([
'name' => 'Global ' . $module->name,
'is_default' => true,
]);So there is always exactly one default version, hard-named "Global {module_name}".
packages/filament-odk-link/src/Filament/OdkAdmin/Resources/XlsformModuleVersionResource.php
- Single
ManageRecordspage (no separate Create/Edit routes) - Form: module selector (shows
"{template title} - {module name}"), version name, file upload only shown whenis_default = false(default versions get their data from the template file, not standalone upload) - Table: template name, module name, is_default boolean icon, version name,
survey_rows_count(computed viacounts()) - No
country_idfield — the package resource predates the January 2025 migration
app/Filament/Admin/Resources/DietDiversityModuleVersionResource.php
- Uses the same
XlsformModuleVersionmodel but scopes the query toxlsform_modules.name = 'diet_diversity'viagetEloquentQuery() - Form:
Hiddenmodule_id hardcoded todiet_diversity, country selector (withafterStateUpdatedthat auto-fillsnameas"diet_diversity {country_name}"), file upload always required - Standard List/Create/Edit page split (not ManageRecords)
- This pattern allows the Admin to add country-specific diet diversity versions that Teams can then select in the App Panel
- Nested inside
XlsformTemplateResource - Columns: label, # survey rows (default version), # choice lists (default version), available locales
- Create/Edit/Delete actions inline
Triggered by Spatie MediaLibrary's MediaHasBeenAddedEvent, handled by HandleXlsformTemplateAdded:
XlsformModuleImport parses the survey sheet, groups rows by module column:
- Rows without a module name get auto-assigned to
"{template_title} - Unspecified Module N"(new module per contiguous unnamed block) - Reads
localisable_modulecolumn:"extend"→can_be_extended = true,"replace"→can_be_replaced = true updateOrCreateonXlsformModule, which auto-fires thebooted()hook to create the default version
XlsformTemplateChoiceListImport (queued)
XlsformTemplateWorkbookImport (queued)
└── chain:
PrepareSurveyRowPaths
FinishSurveyRowImport
FinishChoiceListEntryImport
LinkModuleVersionToLocales ← locales sync
ImportAllLanguageStrings
FinishXlsformTemplateImport ← sets processing = false
The updated_during_import flag on SurveyRow and ChoiceListEntry is the deletion mechanism: anything not touched during the import gets destroyed in afterImport() — this is how re-uploading a file cleanly replaces old content.
GetsModuleNamesPerRow trait routes each survey row to the correct module version:
- If the model is already a
XlsformModuleVersion(standalone upload), use it directly - Otherwise match the
modulecolumn value to a module name - Fallback: match via
row_namesJSON ("type_name"combos stored on the module at import time)
packages/filament-odk-link/src/Jobs/LinkModuleVersionToLocales.php
- Parses column headers to detect languages (
label::language,label::fr, etc.) syncWithPivotValues(..., detaching: false)— adds new locales without removing existing ones- Any locale NOT updated in this import gets
needs_update = true(flagging stale translations) - Works for both a single
XlsformModuleVersionor an entireXlsformTemplate(all default versions) ⚠️ Known n+1 issue noted in a TODO comment — each locale pivot is updated individually
XlsformTemplateSurveyImport uses:
public function uniqueBy(): array
{
return ['xlsform_module_version_id', 'name', 'type'];
}Re-uploading the same file updates existing rows rather than duplicating them.
Xlsform::syncWithTemplate() auto-attaches the default version for any module missing from a form — so new modules added to a template are automatically picked up by existing forms.
Teams can swap to a non-default version. For diet diversity this is handled in Team::booted():
When diet_diversity_module_version_id changes on a Team:
- Finds all forms with a
diet_diversitymodule version - Detaches the old version, attaches the new one at the same
orderposition - Sets
draft_needs_update = trueto trigger redeployment
DietDiversity place adaptation page:
- If a team has no
diet_diversity_module_version_id, auto-selects based onteam.country_id - Select shows only non-default (country-specific) versions
- Preview table of questions in the selected version
- Permission gated:
view adapt diet quality module/maintain adapt diet quality module
CustomModuleVersions Livewire component:
- Each unmatched
LocalIndicatorowns its ownXlsformModuleVersion(viaowner_id) - Teams download a template Excel, fill in questions, upload it
- A single file upload is processed against all unmatched indicator versions at once via
HandleXlsformTemplateAdded::processXlsformTemplate()
WithXlsformModuleVersionQuestionEditing trait — reusable Filament trait for in-UI question editing on any module version:
- Add/Edit/Delete/Reorder questions via modal forms
- Supported types:
select_one,select_multiple,decimal,integer,text - Nested repeaters for language strings (label + hint per locale)
- Nested fieldset for choice lists and their entries
- Editing disabled for file-imported questions (
path != null) - Delete cascades to the associated
ChoiceList
XlsformSurveyExport assembles the final form by querying SurveyRow via a join through the selected_xlsform_module_versions pivot, ordering by:
selected_xlsform_module_versions.order— module position in formxlsform_module_version_id— tiebreakrow_number— question position within module
Two Spatie-permission policies with view/maintain verbs:
| Policy | Permissions |
|---|---|
XlsformModulePolicy |
view xlsform modules / maintain xlsform modules |
XlsformModuleVersionPolicy |
view xlsform module versions / maintain xlsform module versions |
The XlsformModuleVersionPolicy explicitly covers both the package XlsformModuleVersionResource and the app-level DietDiversityModuleVersionResource (same model, noted in a comment at the top of the policy).
| Location | Issue |
|---|---|
XlsformModuleVersion.php:32 |
TODO: implement WithXlsformDrafts for pyxform validation and draft testing of individual module versions |
LinkModuleVersionToLocales.php:68 |
TODO: n+1 queries on locale pivot updates — each locale is updated with a separate query |
XlsformTemplateSurveyImport.php:72-80 |
Commented-out code for prefixing survey row names with team name to ensure uniqueness across teams in custom modules |
add_country_id migration |
No FK constraint on country_id — model has BelongsTo(Country::class) but migration lacks ->constrained() |
XlsformModuleVersionWasImported.php:11 |
Event carries $xlsformModuleId (not a version ID) despite being named *VersionWasImported — misleading |
XlsformModuleVersionResource.php |
No country_id field in the package resource form — country_id only exists in the app-level DietDiversityModuleVersionResource |
XlsformTemplate.php:219 |
TODO: merge datasets + choice lists implementation (currently parallel systems) |