uhifadhi / incident-module
Incidents: what happened in an area — poaching, human–wildlife conflict (with fines and compensation), unauthorized construction and roadkill, as one record type served to every department that needs it.
Package info
github.com/uhifadhilabs/incident-module
Type:symfony-bundle
pkg:composer/uhifadhi/incident-module
Requires
- php: >=8.4
- doctrine/dbal: ^4.0
- doctrine/doctrine-bundle: ^3.0
- doctrine/orm: ^3.5
- fundistadi/postgis-bundle: ^0.5
- symfony/asset: ^7.3 || ^8.0
- symfony/asset-mapper: ^7.3 || ^8.0
- symfony/config: ^7.3 || ^8.0
- symfony/console: ^7.3 || ^8.0
- symfony/dependency-injection: ^7.3 || ^8.0
- symfony/framework-bundle: ^7.3 || ^8.0
- symfony/http-foundation: ^7.3 || ^8.0
- symfony/http-kernel: ^7.3 || ^8.0
- symfony/twig-bundle: ^7.3 || ^8.0
- symfony/uid: ^7.3 || ^8.0
- symfony/ux-icons: ^3.4
- uhifadhi/area-module: ^0.11
- uhifadhi/map-module: ^0.3
- uhifadhi/module-contracts: ^0.5
- uhifadhi/widget-module: ^0.1
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^13.2
- symfony/browser-kit: ^7.3 || ^8.0
- symfony/css-selector: ^7.3 || ^8.0
- symfony/security-bundle: ^7.3 || ^8.0
- symfony/security-core: ^7.3 || ^8.0
- symfony/security-csrf: ^7.3 || ^8.0
- symfony/security-http: ^7.3 || ^8.0
- symfony/stimulus-bundle: ^2.20 || ^2.30 || ^2.0
- uhifadhi/seam-module: ^0.3
- uhifadhi/shell-module: ^0.8
- uhifadhi/storage-module: ^0.2
- uhifadhi/team-module: ^0.8
Suggests
- uhifadhi/seam-module: The per-area module catalogue this module registers itself in, and the grid an area’s Modules tab draws. Without it the incident screens are reachable only by their own routes.
- uhifadhi/shell-module: The page frame every incident screen renders in. Without it the screens have no frame to extend and the module is a register with no way to read it.
- uhifadhi/storage-module: To put an incident’s evidence on the platform’s Files hub (/files). Register the bundle and IncidentFileSource registers itself; without it, incidents simply do not appear there.
Provides
None
Conflicts
None
Replaces
None
README
What happened in an area, recorded once: poaching, human–wildlife conflict (with the fines and compensation that follow), compliance and encroachment, and wildlife mortality. A uhifadhi module bundle.
Contents
What it is
An incident is one event, in one area, at one place, in one category, at one
point in a five-state workflow — reported → verified → in progress → resolved → closed. One record type serves every reader: Protection and Ecology read
subsets of one taxonomy rather than each keeping their own copy.
The module ships nine incident* tables, the report flow, the case file, a
sixteen-widget dashboard surface composed on the shell's widget machinery, and
the Incident kinds editor each area writes its own classification in.
An area starts empty. The module ships no kinds of incident, seeds none and
suggests none: a kind, its colour, the departments a lens puts it in front of,
and under it the sub-categories — their behaviour blocks, which way money runs
and the term each promises — are all the area's own, written in the kinds editor
before the first incident is filed there. What a sub-category's form asks is
not among them: the questions come from the behaviour blocks it switches on and
from nowhere else, so nothing in the product invents a field and there is no form
builder (see docs/the-model.md). Sample
kinds exist only in the devkit demo content, which writes them into an area
through that same editor's service.
Installation
composer require uhifadhi/incident-module
Neither this package nor the core it requires is on Packagist yet, and neither
carries a stable tag, so an installation names where both come from. Composer
reads repositories from the ROOT package only — an entry in a dependency's own
composer.json is ignored — so these lines belong in the application's:
"repositories": [ { "type": "vcs", "url": "https://github.com/uhifadhilabs/uhifadhi" }, { "type": "vcs", "url": "https://github.com/uhifadhilabs/incident-module" }, { "type": "vcs", "url": "https://github.com/uhifadhilabs/storage-module" } ]
The third line is needed only where the installation also wants incident evidence on the Files hub; the second can go once this package is published.
The bundle registers via Flex ("type": "symfony-bundle"), which adds
Uhifadhi\Incident\UhifadhiIncidentBundle to config/bundles.php.
Getting started
Then, in the host:
-
Answer the user contract. Five columns name a person — who reported the incident, who it is assigned to, who acted on the event, who linked it to another, and the team member behind a party to it — and none of them names an account class. They are mapped to
Uhifadhi\Contracts\Entity\UserInterface, and the installation resolves that interface to whatever it calls its people. Install the core (uhifadhi/uhifadhi) and the answer arrives with it — TeamBundle states the resolution from its own bundle; otherwise write one line naming your own class, under theorm:key already inconfig/packages/doctrine.yaml:doctrine: orm: resolve_target_entities: Uhifadhi\Contracts\Entity\UserInterface: App\Entity\Person
Until something answers it, the bundle installs and the kernel boots, but anything that walks the metadata stops on the unresolved interface. Deleting an account later sets those five columns null and leaves the incidents standing, which is why each of those records keeps the person's name beside the relation.
-
Migrate.
bin/console doctrine:migrations:migrate
That is the whole step. This module ships the statements that create its tables, so there is no mappings block to write and nothing to generate:
doctrine:migrations:diffis what an installation runs for the entities IT owns, and after installing or updating this package it must report no changes. The versions add nineincident*tables and nothing else; they alter no host table, and the foreign keys intoarea_of_interest,zoneandteam_userare declared here rather than in the core. -
Write the area's kinds. Open Incidents → Incident kinds in each area (permission
incidents.manage) and name what that area files. Nothing is seeded, so this is the step between installing the module and filing the first incident; a bundle that wrote somebody's classification scheme into their database on boot would be making that decision for them. In a development installation,bin/console fixtures:demowrites a month of sample incidents and the kinds they are filed under. The three Stimulus controllers —incident-filters,incident-board,incident-report— need no step of their own: Flex synchronisesassets/controllers.jsonfrom this package's ownassets/package.jsonon everycomposer require/update, because the package declares thesymfony-uxkeyword.
Everything this module binds to arrives in ONE package, uhifadhi/uhifadhi —
the core, whose five bundles are what these screens stand on: AreaBundle for the
area an incident happens in and its zones, ShellBundle for the page frame and
the widget machinery the dashboard is, AtlasBundle for the maps, RegistryBundle
for the per-area catalogue this module registers itself
in, and TeamBundle for the account class. The contracts it implements ship
inside it. One further package is required: uhifadhi/storage-module stores
the photographs an incident is filed with and puts them on the Files hub.
The one thing an installation still provides is the ACCOUNT CLASS behind the
person contract — see the user contract above. TeamBundle answers it from its
own bundle; an installation with an account class of its own names it in one
line of resolve_target_entities.
Icons need nothing imported. This module registers its own set and draws
under two prefixes only: incident:, answered by the glyphs it ships in
assets/icons/incident, and shell:, answered by the core. No lucide: name is
drawn from here, so a deployment with on-demand fetching off — which is what a
deployment configures — renders every mark on these pages.
The maps
Maps come from the atlas. This module ships no map JavaScript, no Leaflet and no chrome: it states what is on a plate in PHP and renders it with one Twig call.
// src/Service/IncidentMapService.php $map->addLayer(new GeoJsonLayer( id: 'incident.'.$category['slug'], label: $category['label'], features: $collection, swatch: IncidentHues::of($category['colourKey']), shape: LayerShape::Point, count: \count($own), group: IncidentMapService::GROUP, ));
{{ render_map(dashboard.map, {'role': 'img', 'aria-label': 'Where every incident was filed'}, filters) }}
One layer per category in that category's own hue, each legend row a switch; the area's zones underneath, wearing their names; the boundary in the platform's one treatment. The imagery, the control stack, the floating legend and fullscreen are the atlas's, which is why an incident map, a patrol map and the area map read identically. The third argument is this module's filter row, rendered one row above the map and inside the plate, so the chips stay a row in fullscreen.
The full API is the atlas components.
Upgrading
composer update uhifadhi/incident-module bin/console doctrine:migrations:migrate
Again, migrate is the whole of it. New tables and columns arrive as versions
in this package; doctrine:migrations:diff stays what you run for your own
entities, and after this update it must report no changes. If it does report
something, that is a bug in this package — please report it rather than
committing the version it wrote. The one exception is named below.
Upgrading to 0.3: one taxonomy, and it is the area's
Uhifadhi\Incident\Migrations\Version20260911140000 converges the
installation-wide classification onto the per-area one. It runs with migrate
like any other version and needs nothing from you, but it is worth knowing what
it does, because it moves data rather than only schema:
- Every area that has filed an incident is given its own copy of exactly the words its incidents reference — the same wire-code as the old slug, the same label, colour, money direction, term and fields. Two areas that shared a slug end up with two rows, and from here their vocabularies move independently.
incident.taxonomy_subcategory_idis added, filled by (area, slug), and made required once every row has found its area's copy. By construction every row does: the copies are generated from the rows the incidents point at. If any row were left over the column stays optional, the migration raises a warning, andincident.subcategory_idstill holds what those incidents were filed against — point them at one of the area's sub-categories and tighten the column by hand.- Nothing is dropped.
incident.subcategory_id,incident_subcategoryandincident_categoryare kept, still populated, for one release, so you can read what a record used to say and can roll the code back. A later release drops all three in a version marked@destructive. - Until that release,
doctrine:migrations:diffproposes dropping those three things, because the mapping no longer knows them. That proposal is the deferral working, not drift — do not apply it.
Upgrading to 0.3: a word's questions come from its blocks
Uhifadhi\Incident\Migrations\Version20260912103000 moves every answer a
record carries into the shape the behaviour blocks ask in
(incident.block_answers) and adds incident.claimed_at_filing for the figure
the money block asks at filing. It runs with migrate and needs nothing from
you; like the version above it moves data, so it is worth knowing what it does:
- Every answer whose key name says what it answered arrives in the block that
asks it:
snares_liftedbecomes a count row,road_segmentbecomes the kind and the name of a place,suspectsbecomes a party row in the suspect's role. The repeating blocks — counts, parties, seizures, samples, casualties, measures — arrive as rows, in the order the migration's own table names them, so two installations upgrade identically. - Two kinds of key are deliberately left where they are. A key that does not
say what it counted (
quantityanswered "3 sacks, dried" under one word and "2 animals" under another) and a key no block asks for at all (enclosure,crop,circumstances,signs— they came from the retired free-text field list). Choosing a block answer for either would be writing the record. - Nothing is dropped.
incident.detailsandincident_taxonomy_subcategory.field_setare kept, still populated, for one release — which is where those left-behind keys can still be read — anddoctrine:migrations:diffproposes dropping them both until the release that does, in a version marked@destructive. That proposal is the deferral working, not drift: do not apply it. - The figure is not backfilled from a money record. What a money record holds was written by whoever assessed or approved it, in a state past filing; dating somebody else's figure to a moment nobody recorded it at would be a lie about a number.
- After the update the kinds editor no longer asks for a list of field names, and the report form asks exactly what each sub-category's blocks ask. An area whose words were switching on no blocks will see a short step 2 until somebody ticks them: the blocks were always the model, and they are now the whole of it.
If your config/packages/incident.yaml carries an incident.taxonomy tree, the
container refuses to build until you remove the key, and says so in those words.
The tree is not silently ignored, because a deployment that lost its
classification scheme would find out on the first filing screen. Your areas keep
the words they were using: the migration above copied them in.
Before a production run:
# 1. Back up. Nothing below is a substitute for this. pg_dump … # 2. Read what will run, without running it. bin/console doctrine:migrations:migrate --dry-run
Two hatches, for the two ways this goes wrong:
-
An installation that already has the
incident*tables — created by adiffwritten before this package shipped its own versions — must tell the version log they are there, or the first version will try to create them again:bin/console doctrine:migrations:version \ 'Uhifadhi\Incident\Migrations\Version20260910045214' --addThat marks the version executed without running it. Check the table list in
docs/the-model.mdagainst your database first. -
A deployment that applies SQL by hand — a reviewed change window, a database somebody else administers — takes the statements instead of the run:
bin/console doctrine:migrations:migrate --write-sql=incident-upgrade.sql
Learn more
- Charter — one record type and many readers, why departments are a lens and never a fence, and why the dashboard rides the shell's framework.
- The model — the nine tables, and the rules about money (two directions, each recorded from its own place in the workflow), filing and provenance that somebody will otherwise re-argue.
- The workflow, and the definition under it — the five places,
their guards, and how
IncidentWorkflowmaps one-to-one onto a Symfonystate_machine. - Screens — the routes, why the five design directions are presets rather than pages, and the query string another module files with.
- Permissions — the two declared permissions and the sentences the permission matrix prints under them.
- Configuration —
config/packages/incident.yaml, the taxonomy tree, and whatleadsdoes and does not decide. - Evidence on the Files hub — the
uhifadhi/storage-modulecontract, and what this module honestly knows about a file. - Dev tooling — the demo month this module declares for devkit to seed, the two commands that stay, and what the declaration cannot write yet.
- Development —
composer check, the tooling levels, and the real-PostGIS test suites.
License
AGPL-3.0-or-later — see LICENSE: the same license as the uhifadhi host this module plugs into. Use, modify and self-host freely; if you offer a modified version to users over a network, they are entitled to the source of what they're running.