uhifadhi / skeleton
The open-source observatory for nature conservation and protected areas.
Requires
- php: >=8.4
- ext-ctype: *
- ext-iconv: *
- symfony/console: 8.1.*
- symfony/dotenv: 8.1.*
- symfony/flex: ^2.11
- symfony/framework-bundle: 8.1.*
- symfony/mercure-bundle: ^0.5.0
- symfony/runtime: 8.1.*
- symfony/security-bundle: 8.1.*
- symfony/yaml: 8.1.*
- uhifadhi/uhifadhi: ^0.1.8
- utafitilabs/postgis-bundle: ^0.1
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95.25
- phpunit/phpunit: ^13.3.2
- symfony/browser-kit: 8.1.*
Suggests
None
Provides
None
Conflicts
README
The open-source observatory for nature conservation and protected areas.
This repository is the starter every installation is created from: a bare
Symfony application carrying the uhifadhi core. composer create-project copies
it once and then it is yours — every capability after that arrives as a module,
installed with composer.
Contents
- What uhifadhi is
- The tree
- What the core is
- Spatial data and deployment
- Requirements
- Install guide
- What is behind sign-in
- The front door
- Versions and branches
- How it is proven
- Learn more
- Licence
What uhifadhi is
An installation of uhifadhi is one organization's own observatory over the protected areas it manages. Each area is a real place in the database: a gazetted boundary drawn on the map, the zones inside it, and the record of what happens there. Around the areas stands the organization itself — its people, the positions they hold, and the permissions each position carries. On top of that come the capabilities the organization actually runs — patrols, incidents, rosters — and each of those arrives as a module that an administrator installs and then switches on for the areas that want it. An area that runs no patrols never sees the patrol screens.
The whole platform is one sentence:
A module registers with the registry and renders in the shell.
The registry is where a module declares itself — its screens, its place in the catalogue, the permissions it wants an administrator to be able to grant. The shell is the frame every screen is drawn in: the document, the navigation, the theme. Everything a deployment can do arrives as a module on top of those two, and the shell never learns any module's name — it renders what the registry tells it is installed.
A fresh installation is empty, and honestly so. There are no demo areas, no sample team and no pre-installed capabilities: an organization creates its own areas, invites its own people and installs the modules it needs. The install guide below is the ordered path from nothing to that first signed-in screen.
The tree
Uhifadhi is structured like the thing it protects.
The seed is this starter, uhifadhi/skeleton: planted once by
composer create-project, so boring it never changes. The core —
uhifadhi/uhifadhi — is updated
forever through composer, and it holds the registry every module registers with,
the shell you see, the team, the areas and the atlas every map and chart is
drawn with. The branches are the modules, uhifadhi/<name>-module, one per
capability. The contracts
(src/Uhifadhi/Contracts/docs)
are the interfaces every branch carries without carrying the core: a module can
depend on them alone, and they are MIT, because an interface anybody may
implement should cost nobody anything.
The tree is a picture, not a naming scheme. It is the fastest way to explain the shape and it lives in prose only — the packages are named for what they do, so an import says what it is without the metaphor.
What the core is
Two words carry the whole product. The core arrives whole and is never picked apart: the module registry, the shell every screen renders in, the atlas every map and chart is drawn with, the people, and the ground. A module is what an administrator installs on top and switches on per area: patrols, incidents, rosters.
The core is one package, uhifadhi/uhifadhi, and it is what this template
requires. Inside it are five bundles, and a developer reading
config/bundles.php will see all five listed. That list is not a claim that any
of them runs alone:
Each core bundle can be installed into a Symfony application that also has the registry and the shell; Composer enforces that dependency, and a bundle listed in an application's bundle list is not a promise that it runs alone.
Which is why an installer document says "the core" and "modules", and "bundle" is a word for developers.
Spatial data and deployment
Spatial data lives in PostGIS, through
utafitilabs/postgis-bundle,
which the core brings with it. Geometry columns are typed —
geometry(MultiPolygon,4326) for a gazetted boundary, point, linestring —
and they get their GiST indexes from the mapping. The core ships the migrations
that create its own tables, so there is no hand-written DDL anywhere in an
installation and nothing to generate before the first one runs.
Deployment is a standard Symfony application. This repository ships a production
Dockerfile (FrankenPHP): build the image and run it wherever you host
containers, next to any PostGIS database. The image warms the cache when it is
built, through this project's own Composer auto-scripts, so a warm-up that does
not fit fails the build; the container's entrypoint waits for the database,
migrates and syncs the catalogue, and does nothing else. The same image runs
the queue worker (php bin/console messenger:consume …); that start waits for
the database and leaves migrating to the web server's. A web request runs for at
most 30 seconds (max_execution_time, .docker/conf.d/40-requests.ini), above
the core's database statement timeout.
Requirements
PHP 8.4 or newer with the ctype, iconv, intl and pdo_pgsql extensions,
Composer, and a PostgreSQL database with PostGIS.
Turn OPcache on for the command line on a development box — opcache.enable_cli=1
in php.ini — so the console executes cached opcodes instead of keeping every
class it compiles in the process heap.
Install guide
From nothing to a running installation. Each step depends on the one before it — the order is the point.
1. Create the project
composer create-project uhifadhi/skeleton uhifadhi-uca
cd uhifadhi-uca
Use any name you like in place of uhifadhi-uca; it becomes the directory and, in the
next step, the local hostname and the database name.
That installs the core and wires it up: config/bundles.php already names every
bundle, config/packages/ carries one commented file per core bundle plus
security.yaml, and config/routes/ mounts the screens. There is nothing to
paste and no firewall to turn on. What there is not yet is a database.
2. Give it a database
uhifadhi stores gazetted boundaries as PostGIS geometry, so the database needs the PostGIS extension.
The project ships a compose.yaml with a PostGIS image, and .env already
points DATABASE_URL at it:
docker compose up -d
That starts a PostGIS cluster on 127.0.0.1:5432 with the postgis extension
available to the app database. You write nothing into .env.
To use a database of your own instead, set DATABASE_URL in .env.local
yourself. You do not need to run CREATE EXTENSION postgis in it — the core's
first migration does, in step 3 — unless the database will not grant it; see
that step.
3. Run the migrations
The core ships the versions that create its own tables, so there is nothing to generate: you run them.
php bin/console cache:clear --no-warmup php bin/console doctrine:migrations:migrate php bin/console registry:sync php bin/console cache:warmup
Four commands, in that order: the clear and the warm are the two ends of it, and what runs between them needs a cache that already names the installed packages and a database it may write to.
registry:sync prints what it did — the modules added, kept and retired — and
exits non-zero, naming doctrine:migrations:migrate as the step that comes
first, when typed before the registry's tables exist. It is idempotent: an
area's on/off choices and ordering are never revisited by a deploy.
migrations/ in this project stays yours — it is where
doctrine:migrations:diff writes the versions for entities you write in
src/Entity/. A fresh installation has none, and running diff before you have
written an entity is how you confirm that: it says
No changes detected in your mapping information.
Run it with no flag. Each core bundle registers a migrations namespace of its
own, and diff with no --namespace writes into the first one configured —
which, left alone, would be a bundle's directory under vendor/, where the next
composer update deletes the file and the row in doctrine_migration_versions
outlives it. The core puts the directory no installed bundle ships in front, so
the namespace config/packages/doctrine_migrations.yaml maps here —
'DoctrineMigrations': '%kernel.project_dir%/migrations' — is what a flagless
diff falls back to. Keep that mapping: it is the line that makes migrations/
yours. If you add a second namespace of your own, name the one you mean:
php bin/console doctrine:migrations:diff --namespace=DoctrineMigrations
The first version the core runs is CREATE EXTENSION IF NOT EXISTS postgis, so
step 2's CREATE EXTENSION by hand is no longer something you do — unless your
database refuses it. PostGIS is not a trusted extension, so enabling it wants a
superuser; a hosted database that withholds that has PostGIS turned on by the
provider, and the core's first version then runs and does nothing.
There is no asset step here. In development AssetMapper serves every stylesheet
and script straight from its source; compiling them (asset-map:compile) is a
build step, and the production Dockerfile runs it when the image is built.
4. Create the first administrator
The firewall is on from the moment the project exists, and a fresh installation has no account to get through it. This step is how the first administrator comes to exist, and it runs after the migrations, because it writes to the table they just created. The command ships with the core, so a production image has it too, and it asks for everything it needs — the address, the two names, the tier, and last the passphrase, which is never echoed:
php bin/console team:user:create
The tier defaults to super-admin, which is what this account is for: the first
administrator of an installation with nobody else in it. --tier=admin and
--tier=staff make lesser accounts once somebody can sign in.
For a script, everything can be given on the line, with the passphrase read from standard input so it never reaches a shell history or a process list:
printf '%s' "$PASSPHRASE" | php bin/console team:user:create you@example.org Ada Mwangi --tier=super-admin
5. Serve it
symfony server:start -d
Open the address it prints and sign in as the administrator from step 4.
Without the Symfony CLI, php -S 127.0.0.1:8000 -t public serves it for a
quick look.
6. Run the worker
Work a page hands over — and the default schedule's recurring tasks — runs in
the queue worker, one long-running console command:
php bin/console messenger:consume async scheduler_default -vv
Until it runs, handed-over work waits in the database and nothing is lost. With
fundi serving the project, declare
it once in .fundi.local.yaml and fundi server:start runs it next to the web
server and starts it again when it stops:
workers: queue: cmd: [php, bin/console, messenger:consume, async, scheduler_default, --time-limit=3600, --memory-limit=256M]
In production the worker is a role of the deployment on the same image: with
fundi, a deploy.workers entry in the same file, which fundi deploy:init
writes into config/deploy.yml. After a deploy, the worker is a new container
running the new code; anywhere else, run php bin/console messenger:stop-workers
after the code changes, so a worker finishes its message and is started again on
it.
7. Add modules
Installing a module is composer require uhifadhi/<name>-module, then the same
four lines as any upgrade, because a module adds its own tables and its own
assets — and, like the core, ships the versions that create them:
composer require uhifadhi/storage-module php bin/console cache:clear --no-warmup php bin/console doctrine:migrations:migrate php bin/console registry:sync php bin/console cache:warmup
The third is what enters the module in the catalogue and gives every existing area its row. A module ships its own versions, so the installation's own namespace must then have nothing to write — every package keeps its migrations under a namespace of its own, which is why the command names one:
php bin/console doctrine:migrations:diff --namespace=DoctrineMigrations # No changes detected
An administrator then switches the module on for the areas that want it, from the area's Modules section under its Configure action.
Official modules
These are the modules the platform ships and keeps in step with the core.
Install them in this order — a module that builds on another comes after it —
each one with its composer require and the four commands above. What a module
needs beyond that (its configuration, its own database or storage, the settings
an administrator fills in) and how it is switched on for an area is in the
module's own README, which is the reference for that module:
| Module | What it adds | Install and switch on |
|---|---|---|
uhifadhi/storage-module |
where evidence and photographs are kept; the modules below store through it | storage-module README |
uhifadhi/patrol-module |
patrols: planning, the handset's tracks and observations, maps, the calendar, exports | patrol-module README |
uhifadhi/incident-module |
incidents: the register, the board, area lists, evidence | incident-module README |
uhifadhi/roster-module |
duty: shifts, check-ins, who is on watch where | roster-module README |
Official development modules
Required with --dev, so a production build never carries them:
| Module | What it adds | Install |
|---|---|---|
uhifadhi/devkit-module |
demo content and the commands that seed it; every module's development commands in one place | composer require --dev uhifadhi/devkit-module · devkit-module README |
What is behind sign-in
Everything, and that is the installation's one rule.
config/packages/security.yaml names the four addresses a stranger has to reach
— sign-in, the forgotten-password screen, an invitation link, and the endpoint a
field client gets a token at — and shuts everything else, including every route
a module adds tomorrow. Installing a module never means editing that file.
What a signed-in person may do is not decided there. Each module declares its own permissions and checks them in its own controllers, per action, per object and per area — which a path rule could not express anyway.
The front door
/ is the organization's dashboard. What it shows is chosen on the dashboard
itself: an administrator picks one of the shipped presets or arranges the
widgets, per installation and per person, and nothing about it is configured in
files. The settings section (/settings) and the favicon are mounted by
config/routes/shell.yaml.
Versions and branches
Every repository in the fleet — this starter, the core, each module — is
branched one way: a branch per version line, named after it (0.1,
0.2, …), the newest line being the default branch where new work lands, and
tags on those branches as the releases. There is no main. composer.json
requires the core with a caret (^0.1), which resolves to the latest tag on
that line; the same goes for every module in the table above.
Every package of the fleet is listed on Packagist, so a plain
composer require finds it. Telemetry, the managed-hosting tier's module, is
private and not part of an installation's own list.
How it is proven
Every tag in the fleet is minted by a Release workflow in the repository being
tagged, and that workflow runs the fleet gate — bin/console fleet:gate, a
command of uhifadhi/devkit-module — on both sides of the tag: against the
branches before it, and against the published packages after it. The gate does
precisely what the install guide above does, in a directory that did not exist a
minute ago, and then installs every official module into it one by one and opens
each module's first page as an administrator. A tag is not done until the
published fleet installs. The steps, the modes and how to read a red run are in
devkit-module's docs/fleet-gate.md.
Learn more
- The architecture — what this repository is, what the core is, and what is deliberately not here.
- Maintaining the skeleton — what happens
to this copy after
create-project, and how an installation takes a new core.
Licence
AGPL-3.0-or-later — see LICENSE. Use, modify and self-host freely; if you offer a modified uhifadhi to users over a network, they are entitled to the source of what they're running. Science is never paywalled.