uhifadhi / area-module
Area: what an installation manages — the named protected area, its gazetted boundary as a PostGIS multipolygon, and the answer to the seam's area contract so nothing has to be written by hand.
Package info
github.com/uhifadhilabs/area-module
Type:symfony-bundle
pkg:composer/uhifadhi/area-module
Requires
- php: >=8.4
- doctrine/dbal: ^4.0
- doctrine/doctrine-bundle: ^3.0
- doctrine/orm: ^3.5
- fundistadi/postgis-bundle: ^0.5.1
- symfony/config: ^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/routing: ^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/uid: ^7.3 || ^8.0
- twig/twig: ^3.12
- uhifadhi/map-module: ^0.1.4 || ^0.2
- uhifadhi/seam-module: ^0.1.2 || ^0.2
- 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/twig-bundle: ^7.3 || ^8.0
- symfony/yaml: ^7.3 || ^8.0
- uhifadhi/module-contracts: ^0.3
- uhifadhi/shell-module: ^0.1
Suggests
- uhifadhi/shell-module: The page frame these screens render in, the tab strip above an area and the sidebar this module contributes its Areas section to; without it the screens still work and simply render unframed.
Provides
None
Conflicts
None
Replaces
None
README
Area: the named piece of ground an installation manages — its gazetted boundary, and the answer to the seam's area contract so nothing has to be written by hand. A uhifadhi platform module.
Contents
- The architecture
- What it owns
- What a module contributes to an area
- The screens
- Whoever knows the answer states the resolution
- Why it declares no module
- What is here
- Installation
- Upgrading an installation that has the placeholder
- Upgrading from 0.3 to 0.4
- Upgrading from 0.4 to 0.5
- Not here yet
- Development
- License
The architecture
Uhifadhi is one skeleton and a set of modules. uhifadhi/uhifadhi is the
project skeleton — copied once, never updated; everything else arrives as a
module, updated forever. A module registers with the seam
(uhifadhi/seam-module) and renders in the shell
(uhifadhi/shell-module); everything a deployment can do — patrols, incidents,
rosters — is a module.
The skeleton is the application, the seam carries the modules, the shell is what you see, team is who is looking, and this is where. An area is the axis the whole product is filed under: a patrol happens in one, an incident is reported in one, a module is switched on for one. Until this module existed, every installation wrote its own — because the platform asked every installation for an area and shipped none.
What it owns
Two entities and the seams a module contributes to an area through.
The area
| Field | What it is |
|---|---|
name |
What the area is called |
geom |
The boundary — a MultiPolygon in WGS84, exchanged as GeoJSON |
source |
Where the boundary came from: WDPA, a shapefile's name, drawn |
iucnCategory |
IUCN protected-area category (II, VI, …), optional |
establishedYear |
Year gazetted, optional |
uuid |
The public identifier — every URL and API response names an area by this |
createdAt / updatedAt |
Stamped by lifecycle callbacks |
MultiPolygon, not Polygon, because a gazetted boundary is regularly more
than one ring: an enclave, an outlying block, a lake excluded from the middle.
The column is the fundi-postgis geometry type, so
fundistadi/postgis-bundle is a
requirement of this module and PostGIS a requirement of the database.
The registry fields are optional, and that is honest rather than lax. An installation that drew its own boundary on a map has no IUCN category and no gazettement year, and must not be made to invent them to save a record.
Addressed by UUID. The sequential id exists so foreign keys are cheap and
never appears in a URL. The repository extends the PostGIS bundle's spatial base,
so stAreaKm2() and findStIntersecting() are there without this module writing
a line of SQL; the one method it adds is findOneByUuid().
The zone
A zone is a named polygon subdividing one area — the spatial lens, the way a department is the organisational one. Zones are data an admin draws or uploads, never code: a module asks generic questions of them ("which zone is this point in?") and never names one, because the names are one installation's geography.
| Field | What it is |
|---|---|
name |
What the zone is called — unique per area, so two areas may each have a "North" |
area |
The area it subdivides — NOT NULL, and the foreign key cascades |
geom |
A MultiPolygon in WGS84, like the area's |
uuid |
The public identifier |
createdAt / updatedAt |
Stamped by lifecycle callbacks |
Sibling zones never share interior. Adjacency is legal — two zones may meet
along an edge — and so are gaps, because an area is regularly only partly zoned.
That is exactly the DE-9IM pattern T******** and not ST_Overlaps, which
PostGIS defines as false when one geometry contains another; containment is a
conflict the invariant has to catch. The rule is not expressible as a column
constraint, so ZoneService is the only supported way a zone gets a
geometry — anything that writes one around it writes an overlap nobody notices
until a point falls in two zones at once.
An area with no zones is the normal state, not a configuration somebody
forgot. ZoneService::zoneOf() answers null without complaint, and every
consumer treats "unzoned" as a first-class answer.
The table is zone, the same compatibility promise area_of_interest
carries: an installation that wrote this entity by hand keeps its rows.
What a module contributes to an area
/areas/{uuid} is the first surface in the platform whose widgets are not
written by whoever owns the page. The host owns the surface, the grid and the
identity of the area; every operational widget arrives from a module installed
in that area. These are the seams it arrives through.
| Seam | Tag | What a module puts on the page |
|---|---|---|
Overview\OverviewContributorInterface |
uhifadhi.overview.widget_provider |
Its own widgets, and the library section they sit under |
Overview\NowTileProviderInterface |
uhifadhi.overview.now_tile |
A tile in the right-now strip |
Overview\AttentionProviderInterface |
uhifadhi.overview.attention |
A row in "Needs attention" |
Overview\MapLayerProviderInterface |
uhifadhi.map.layer |
A layer on the operational plate, with its legend |
Overview\PulseProviderInterface |
uhifadhi.overview.pulse |
Its moves in the area pulse |
Overview\OverviewCopyProviderInterface |
uhifadhi.overview.copy |
Its own words inside a host sentence |
Overview\ContributesStylesheetInterface |
— | The stylesheet its markup needs, since somebody else renders it |
Kpi\DepartmentKpiProviderInterface |
uhifadhi.department_kpi |
A figure on a department's performance surfaces |
Absent is never zero. Every one of these may answer [], and that is the
right answer rather than a gap: a module with nothing to say puts no tile in the
strip instead of a tile reading 0, and an unmeasured figure renders as a dashed
slot instead of a 0% that claims a measurement nobody took.
Tag explicitly, and write the tag as a literal. A reusable bundle is not
autoconfigured, so a contributing module tags its provider by hand in its own
extension — and writes 'uhifadhi.overview.now_tile' rather than reading
NowTileProviderInterface::TAG, because reading the constant loads a class from
a package that need not be on its build classpath. The constant and the literal
are kept equal by tests/Unit/Overview/ContributionSeamContractTest.php; change
one without the other and contributions land in a tag nobody collects, with no
error anywhere.
Why these interfaces live here
An interface a module implements is normally hoisted into
uhifadhi/module-contracts, and these are not. The precedent followed instead is
uhifadhi/widget-module's: WidgetSurfaceInterface lives in the package that
owns the surface, and a consumer hard-requires that package.
The tension is real and worth stating. Hoisting exists so two modules can
agree on a promise without either depending on the other, and by that rule these
belong in contracts. What decides it the other way is that every one of these
methods takes an AreaOfInterest: an area-scoped module needs the area entity
anyway, so requiring this package costs it a dependency it already has, while
hoisting would put an interface in contracts whose signature names a class
contracts does not publish. If a department contract is ever published and the
KPI seam stops needing an area, that argument weakens and the hoist should be
revisited.
Departments arrive as a ref, not as an entity. DepartmentKpiProviderInterface
takes a Kpi\DepartmentRef — an id, a uuid and a name — because departments
belong to uhifadhi/team-module and no package publishes a contract for one.
Typing the seam against Uhifadhi\Team\Entity\Department would make every module
that reports a figure hard-require team; typing it against nothing would hand
providers an object to guess at. The caller resolves the ref, and a provider
that needs to know whose rows these are reads the org chart the way the fleet
already does — off the mapping, through the class the installation resolved the
user contract to, never through a getPosition() no contract promises.
The screens
Seven, mounted by the recipe's config/routes/area.yaml and yours to prefix,
restrict or remove.
| Screen | Route | Gate |
|---|---|---|
| The register — every area | area_index · /areas |
area.view |
| Create an area | area_new · GET,POST /areas/new |
area.create |
| One area's overview | area_show · /areas/{uuid} |
area.view |
| Its module grid | seam_area_modules · /areas/{uuid}/modules |
module.view |
| Its module shop | area_module_customize · /areas/{uuid}/modules/customize |
module.create |
| Its zones | area_zones · /areas/{uuid}/zones |
area.view |
| Its settings | area_settings · /areas/{uuid}/settings |
area.edit |
Three POST addresses sit under the shop and carry the same module.create gate
plus a CSRF token scoped to the area: area_module_install,
area_module_uninstall and area_module_reorder.
Gated on the platform's permission strings and on nothing else. area.view,
area.create, area.edit, area.delete, module.view and module.create are
in the catalogue
uhifadhi/team-module ships, and team's voter answers them at runtime — this
module names them and does not depend on team, so an installation may answer
them with something else without touching a screen.
Addressed by UUID. Every route carries the uuid requirement, so /areas/2
is a 404 rather than a sequential key anybody can walk.
The shell is a suggest, not a require. These screens render in the
shell's frame where an installation has one. The wiring for them lives in
config/screens.php and is imported only where the application has both
TwigBundle and SecurityBundle — an installation that wants the area MODEL and no
pages (a console importer, an API) still boots, where a controller depending on
a non-existent twig service would have failed at compile time.
Where you are, and what is in the sidebar
This module answers two of the shell's seams, and both read one list so they cannot disagree:
AreaShellSourceInterface— the tab strip above an area, and the area's name for the page title. Aliased toshell.area_shell_source; an installation whose areas are its own model aliases that id to its own class instead.NavigationSourceInterface— the Observatory → Areas section, the register and every area unfolding to its own screens. Taggedshell.nav_section.
Both are route-tolerant: unmount a route and its tab or row is simply absent
rather than every page failing. That is deliberate, so removing a screen is a
supported thing to do. The Modules tab appears only where an installation mounts
a seam_area_modules route — which this module has mounted since v0.5.0, and
which you may still unmount.
A tab or row the viewer may not have is ABSENT, never greyed out. A disabled "Settings" tells a ranger a screen exists and they are not trusted with it, which is a worse product than not mentioning it.
What the register shows, and what it deliberately does not
The register lists only what this module owns: the area's identity, its measured size and centre, how it is subdivided, and what the seam has switched on in it.
The design draws more — forest cover, tree-cover loss, an ha/yr rate, a trend sparkline and an alerts chip. Those columns are absent here, and the reason is architectural rather than a missing feed. The design was drawn for the monolith, where every one of those figures was the same application's to read. In a fleet of modules they belong to a future ingestion-shaped module, and a register that reserved labelled columns for them would be this module knowing what that module measures — precisely the coupling the overview's contribution seams exist to prevent.
Per-module register columns are a contribution seam that does not exist yet. When it lands, a module will add its own column the way it already adds its own overview widget and its own now-tile. Until then the columns are ABSENT rather than empty: a permanently dashed "forest" column is furniture that teaches somebody the product is broken.
The area thumbnails the design draws are also absent — rendering one is
uhifadhi/map-module's business, and this module does not depend on it.
Creating an area
A name and a GeoJSON boundary, gated on area.create — and the button on the
register carries the same gate, because a control that opens onto a refusal is a
worse answer than no control.
The file may be a Polygon, a MultiPolygon, a Feature or a
FeatureCollection; all four become the one MultiPolygon the column takes,
because a gazetted boundary is regularly more than one piece of ground — an
enclave, an outlying block. Several features are merged into one boundary
rather than the first one taken.
GeoJSON only, and the screen says so. The application this was ported from
also accepted zipped Shapefiles, GeoPackages, KML/KMZ and File Geodatabases by
shelling out to GDAL's ogr2ogr. A reusable bundle will not put a system package
between an installation and its first area, so that path is not here; the drop
zone's caption names what this module can actually read rather than five formats
it would refuse four of.
Nothing is reprojected. RFC 7946 defines GeoJSON as WGS84, which is what the
column's typmod declares. Nothing is parsed in PHP either: the geometry
reaches PostGIS as GeoJSON and ST_GeomFromGeoJSON decides validity at the
insert, with the whole boundary in hand.
A refusal is the same page with a sentence on it and a 422 — never a redirect and never an error page. The typed name survives it.
The form carries a CSRF token, like every other write in this module.
area.create answers who may create an area; the token answers whether this
page asked, and a permission is no defence against a form on somebody else's
site posting here with the viewer's own cookie.
The module grid and the shop
seam_area_modules is a fleet route name, not this module's invention. It
existed as a name before it existed as a route: this module's own tab strip has
listed it since v0.4.0, and the patrol module's breadcrumb and dashboard
back-button generate it blind and print plain text when nothing answers. Mounting
it here lights all three with no change to any of them.
This module owns the screen; the seam stays UI-less. The grid is a reading of the seam's catalogue against an area's ledger, and "an area" is this module's word — the seam holds that table for installations whose area model is their own and cannot name an area class, let alone draw a page about one. So the seam publishes the data and this module draws it.
The picture is the shell's. Tiles render through
@UhifadhiShell/_module_grid.html.twig, the same partial a department page would
use, because the catalogue picture must look identical wherever it appears. What
is not the shell's is which cards, in which groups, with which URLs: that
needs the area, the viewer and the ledger. See src/Service/AreaComposition.php.
The grid shows what is ON. A tile for a parked module would open onto a page the area has switched off; parked modules live in the shop, which is the screen for changing your mind about them. A tile whose module declares no entry route, or whose route this installation has not mounted, is inert rather than a link.
Every write goes through the seam's AreaModuleService — nothing here writes
an area_module row by hand, because the rule that a pinned module cannot be
parked lives there and a second writer would eventually disagree with it. The
seam needed no new operation for this screen: install, uninstall and
reorder were already published.
Two permissions, and the mapping is deliberate. module.view to see the
grid; module.create — the catalogue's Modules / Add — to reach the shop and
to move anything in it. The application this was ported from gated its customize
screen on ROLE_ADMIN while its own docblock claimed module.create; the
catalogue string is the honest one, because composing an area is exactly the
capability it describes and a role is not grantable to a position.
The tiles carry no headline figure, and that is a missing seam rather than a missing style. The design draws a statistic, a sparkline and a one-line summary on each tile. Every one is a module's own reading of its own data, and there is no contribution seam for them — the catalogue publishes a slug, a name, a category, a status and a data source, and that is all anybody can honestly print. When a module can contribute a tile figure the way it already contributes an overview widget, the tiles grow one and the template does not change.
The stylesheet
bundles/uhifadhiarea/area.css, named by
UhifadhiAreaBundle::STYLESHEET and linked by
templates/_stylesheets.html.twig after the shell's own. It declares no
colours and no fonts of its own — every value is one of the shell's --c-*
tokens or --font-mono — so theming the shell themes these screens and dark mode
needs nothing here.
Whoever knows the answer states the resolution
The rule, for the whole fleet: the package that provides the answer is the
package that states the resolution. Team knows what its User is and prepends
that mapping; area knows what its AreaOfInterest is and prepends this one. An
installation writes a resolve_target_entities line only when it wants to
disagree — and then its line wins, because prepended configuration loses to
the application's by Symfony's own design.
The corollary is the point of this ring: once the answer-modules are
installed, a bare installation reaches doctrine:migrations:diff with zero
doctrine edits.
Here is the seam's half of it. The seam owns the record of which modules an area
has switched on, and it cannot name an area class — it holds that table for
installations whose area model is their own — so it maps the association at
Uhifadhi\Seam\Entity\AreaInterface. Somebody has to close the loop. For as long
as this module is installed the answer is not in doubt:
# what the bundle prepends for you — you do not write this doctrine: orm: resolve_target_entities: Uhifadhi\Seam\Entity\AreaInterface: Uhifadhi\Area\Entity\AreaOfInterest
It used to be a documented hand-step, and it was the fleet's oldest: write a
placeholder class in src/Entity/, then uncomment a block in
config/packages/seam.yaml. That was the wrong shape twice over. A hand-step is
for a decision only the installation can make, and "what is an area" was only a
decision because nothing shipped one. And its cost was real, because forgetting
either half fails a long way from its cause — the container compiles, the kernel
boots, and then:
$ bin/console doctrine:migrations:diff In MappingException.php line 72: Class 'Uhifadhi\Seam\Entity\AreaInterface' does not exist
with nothing pointing back at the paragraph that was missed.
If your areas are your own entity, say so and you win. Name your class in your own config and there is nothing here to disable first:
# config/packages/doctrine.yaml — yours, and it overrules the bundle doctrine: orm: resolve_target_entities: Uhifadhi\Seam\Entity\AreaInterface: App\Entity\ManagementUnit
Merge it into the block already there — that file opens with doctrine:, and
a second doctrine: key in the same file is not valid YAML;
resolve_target_entities goes under the existing orm:, beside mappings. Your
class has to answer getId() and be in the mapping chain. That the override wins
is tested, not assumed
(tests/Integration/Resolution/ResolveTargetEntitiesTest).
Why it declares no module
An area is not a module of itself. Every other bundle in the fleet carries
the uhifadhi.module tag and takes a tile in the catalogue. The catalogue is
indexed by area — the seam's area_module row says "this area has this
module switched on" — so a provider here would write a row for every area saying
that the area has areas: switchable, meaningless, and shown in the module grid of
the page it is the subject of.
Areas are the axis the catalogue is indexed by, not an entry in it. This is the thing an area is, not a capability an area has.
That axis role is why area belongs to the infrastructure tier rather than the
capability tier. The fleet has two: a capability module (patrol, incident)
is the per-area grid an admin switches on, default off, ledgered per area; an
infrastructure module (area, map, widget, storage, team) is machinery every
relevant screen already relies on — installed means on, never in the catalogue,
grid, or ledger. Neither area nor map is a per-area toggle, so neither carries a
uhifadhi.module provider.
Because an area's boundary is drawn on its own map, this bundle hard-requires
uhifadhi/map-module (infrastructure, guaranteed present through the composer
graph rather than a ledger row). Map is therefore never absent, so nothing here
carries an "if the map is available" branch: the boundary is area's own data — a
base layer, not a module contribution — and capability modules (patrol tracks,
incident pins) add their layers on top when switched on.
What this bundle registers with the seam instead is the only thing the seam ever
asked of an installation — the answer to its area contract. That is a deeper integration
than a catalogue tile, not a shallower one: without it the seam has no schema at
all. The absence of the tag is pinned by
tests/Integration/CatalogueAbstentionTest.
What is here
| Piece | File |
|---|---|
| The Symfony plug, the mapping and the resolution | src/UhifadhiAreaBundle.php |
| The area | src/Entity/AreaOfInterest.php |
| Its spatial repository | src/Repository/AreaOfInterestRepository.php |
| The zone | src/Entity/Zone.php |
| Its spatial repository, and the two invariant predicates | src/Repository/ZoneRepository.php |
| The only supported way a zone gets a geometry | src/Service/ZoneService.php |
| What a broken zone invariant reads like | src/Exception/ZoneOverlapException.php |
| Whatever polygonal GeoJSON arrived, as one MultiPolygon | src/Service/GeoJsonNormalizer.php |
| A file becomes an area | src/Service/BoundaryImport.php |
| What a refused import reads like | src/Exception/BoundaryImportException.php |
| An area's composition, read from the seam's services | src/Service/AreaComposition.php |
| A module as the shop prints it | src/Model/ModuleRow.php |
| The overview contribution seams | src/Overview/ |
| The department-KPI seam | src/Kpi/ |
| Static service wiring — the model | config/services.php |
| Static service wiring — the screens | config/screens.php |
| The screens | src/Controller/ |
| Where you are, and the sidebar section | src/Shell/ |
| The area vocabulary | public/area.css |
| The register's search, filter and sort | assets/controllers/area_register_controller.js |
| The boundary drop zone | assets/controllers/area_upload_controller.js |
| Dragging a module into place | assets/controllers/module_order_controller.js |
| The templates | templates/ |
| An installation, minimally | tests/Integration/TestKernel.php |
| The gap this module closes | tests/Integration/InstallabilityTest.php |
| The tag literals the fleet writes by hand | tests/Unit/Overview/ContributionSeamContractTest.php |
| The design workshop's labels do not ship | tests/Unit/Template/NoWorkshopLabelsTest.php |
There is no configuration tree and the recipe ships no
config/packages/area.yaml. Nothing here is an installation's to set: the
entity has no options, the table name is a compatibility promise rather than a
preference, and the one decision anybody could want to make — "my areas are my
own class" — is made by Symfony's override rule above, not by a key invented
here. A config file that exists only to sit empty is a file somebody has to read.
Installation
composer require uhifadhi/area-module
Flex registers the bundle ("type": "symfony-bundle") and brings
fundistadi/postgis-bundle with it, which is registered the same way. That is
the whole installation: the bundle maps its own entity and answers the seam's
contract, so there is no doctrine.yaml edit of any kind.
Your database needs PostGIS:
CREATE EXTENSION IF NOT EXISTS postgis;
Then the tables, in the ordinary order:
bin/console doctrine:database:create
bin/console doctrine:migrations:diff # your history, your migration
bin/console doctrine:migrations:migrate
bin/console seam:catalogue:seed
This module ships entities, not migrations. The tables are the bundle's; the
migration history is yours, and a vendor replaying its own versions into it would
fight every diff you ever run.
The seed reads areas through the resolved class, which is why it works now. An installation whose interface was unresolved got a catalogue, no per-area rows, and a success message — the seed treats "no area class" as "no areas to backfill" rather than as a fatal, so the omission was silent.
Upgrading an installation that has the placeholder
An installation created before this module exists was told to write its own
App\Entity\AreaOfInterest — getId() and nothing else — and to uncomment the
resolution in config/packages/seam.yaml. Adopting this module needs no rename
migration, and the steps below are a transcript rather than a sketch: they were
walked on a create-project installation built the old way, with a row in its
area_of_interest table.
1. Your database needs PostGIS, which an installation with a one-column placeholder does not have:
CREATE EXTENSION IF NOT EXISTS postgis;
2. Install the module.
composer require uhifadhi/area-module
3. Delete both halves of the old hand-step. Remove your placeholder class
(src/Entity/AreaOfInterest.php) and the resolve_target_entities block you
uncommented in config/packages/seam.yaml. Both have to go, and forgetting the
class is the loud one — two entities claiming one table:
$ bin/console doctrine:migrations:diff The table with name "public.area_of_interest" already exists.
Forgetting only the config block is quieter and worse: a resolution left in place still wins, naming a class you just deleted.
4. Diff. The table does not move — this module's table is area_of_interest,
stated explicitly and pinned by a test, and that is exactly the table the
documented placeholder produced under the skeleton's underscore naming strategy.
Your rows, your foreign keys and the seam's area_module.area_id all stay where
they are. Every line of the generated migration is an ADD:
ALTER TABLE area_of_interest ADD name VARCHAR(128) NOT NULL; ALTER TABLE area_of_interest ADD geom geometry(MULTIPOLYGON,4326) NOT NULL; ALTER TABLE area_of_interest ADD source VARCHAR(64) NOT NULL; ALTER TABLE area_of_interest ADD iucn_category VARCHAR(8) DEFAULT NULL; ALTER TABLE area_of_interest ADD established_year INT DEFAULT NULL; ALTER TABLE area_of_interest ADD created_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL; ALTER TABLE area_of_interest ADD updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL; ALTER TABLE area_of_interest ADD uuid UUID NOT NULL; CREATE UNIQUE INDEX UNIQ_FB9E46FCD17F50A6 ON area_of_interest (uuid); CREATE INDEX idx_area_of_interest_geom_sp ON area_of_interest USING gist (geom);
No RENAME, no DROP TABLE, and the seam's foreign key is not in the diff at
all.
5. Mind the four NOT NULL columns if your table has rows. A placeholder area
had nothing on it but an id, so there is no value a migration could infer, and
running the generated one as-is stops:
SQLSTATE[23502]: Not null violation: 7 ERROR: column "name" of relation "area_of_interest" contains null values
If the table is empty, run the migration as generated. If it is not, edit it:
add each of name, geom, source and uuid nullable, UPDATE the rows with
the name, boundary and identifier the area actually has, then ALTER … SET NOT NULL. That is a migration only your deployment can write, because only your
deployment knows where its boundary comes from.
Upgrading from 0.3 to 0.4
One column is dropped: tree_cover_pct.
It was a single ingestion-shaped module's reading persisted on the entity every other module is filed under — the monolith's coupling, carried across in the port because it was already in the table. No module in the fleet ever wrote it and nothing read it. A field nobody may use is still a field everybody has to read past, so it is gone rather than deprecated: when a module owns that figure it will contribute it the way a module already contributes an overview widget.
Run the diff and you get one line — this is the real generated statement, not a sketch:
ALTER TABLE area_of_interest DROP tree_cover_pct;
$ php bin/console doctrine:migrations:diff $ php bin/console doctrine:migrations:migrate
If your installation actually put numbers in that column, they are the one thing this drop destroys — copy them out before migrating. Nothing in the published fleet could have written them, so for almost every installation the column is null in every row.
Nothing else moves: no table is renamed, no other column changes, and the seam's
area_module.area_id foreign key is not in the diff at all.
Upgrading from 0.4 to 0.5
No migration, no configuration, no code change. v0.5.0 adds screens and adds
nothing to the schema: the create flow writes the area_of_interest row that
already existed, and the module shop writes the seam's area_module table
through the seam's own service. composer update uhifadhi/area-module is the
whole of it.
Two things change on their own, and both are the point:
- The register's
+ New areabutton starts working. It rendered a literal/areas/newhref that 404'd; it is nowpath('area_new'), gated onarea.create, and hidden from anybody who does not hold it. An installation whose people should not create areas needs to check thatarea.createis not in more positions than it means. - A Modules tab appears above every area, and in the sidebar under it, for
anybody holding
module.view— becauseseam_area_modulesnow answers. Elsewhere in the fleet the same route lights the patrol module's breadcrumb and its dashboard back-button, with no change to that package.
symfony/security-csrf joins the requirements — the shop writes, so every
control on it carries a token. Composer pulls it.
If you had mounted your own seam_area_modules route, yours still wins:
routes are the application's, and the recipe's import is a file in your
config/routes/ that you may edit or delete.
Not here yet
Stated so nobody looks for them. This module is two entities, a resolution and a set of seams; none of the following is in it, and that is a scope decision rather than an oversight.
- No boundary import beyond GeoJSON. A shapefile, a GeoPackage, a KML or a
WDPA extract is refused by name. Converting them means
ogr2ogron the host, and a reusable bundle will not make a system package the price of a first area; the path returns behind an optional collaborator. The same goes for zones: the entity and its invariant are here, a zone importer is not. - No live import transcript. The design draws a second card on the create screen that narrates an import step by step over Mercure. Every step it narrates belongs to the conversion path above; what is left — parse one document, insert one row — finishes in milliseconds and has nothing to watch. The card returns with the thing it describes.
- No zone creation screen. Zones can be read and are drawn on the area's zoning lens; there is no screen for making one. The design workspace has not drawn it, and this module does not invent UI.
- No module reordering without JavaScript. Switching a module on or off is a real form and works with scripting off; the drag ordering is not, and that is the one preference on the shop that scripting buys.
- No map rendering. Drawing a boundary is
uhifadhi/map-module's business, and this module does not depend on it.MapLayerdescribes what a module contributes to a plate; it does not draw one. - No API. Serving an area to a field application is an API surface with a contract of its own.
- No fixtures. Making an area for a demo installation belongs to whatever seeds it.
- No tree-cover figure, and no other module's measurement either. An area is
a name, a boundary, where that boundary came from and its registry facts. A
reading belongs to whatever module measures it, and reaches a page through a
contribution seam — never through a column reserved on the entity everything
else is filed under. Pinned by
Integration\Area\AreaPersistenceTest::testTheAreaHoldsNoForeignModulesFigure, which asserts the whole property list rather than the one field that was removed.
Development
composer install
composer check # cs:check -> phpstan (max) -> the suite
- PHP 8.4+, PHPStan level max, php-cs-fixer
@Symfony+@Symfony:risky. - Tests first, always. A behaviour change starts as a failing test naming the
class or service id it wants; the change is the commit that makes it pass. CI
gates on
composer check: one suite, one verdict. - The suite talks to a real PostGIS database named by
AREA_TEST_DATABASE_URL(seephpunit.dist.xml) and rebuilds the schema per test — a boundary column only ever asserted against a mock would prove nothing. On the fundi cluster, once:
createdb -h 127.0.0.1 -p 5434 -U app area_bundle_test
psql -h 127.0.0.1 -p 5434 -U app -d area_bundle_test -c 'CREATE EXTENSION IF NOT EXISTS postgis;'
License
AGPL-3.0-or-later — see LICENSE. 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.