Search by

uhifadhi / skeleton

eemjema

The open-source observatory for nature conservation and protected areas.

Package info

github.com/utafitilabs/uhifadhi-skeleton

Type:project

pkg:composer/uhifadhi/skeleton

Statistics

Installs: 97

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0


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

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 seeded 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, and where and how to host it is the installation's choice: this repository ships no hosting configuration — no image, no web-server file, no deploy tool. What any host has to do is the same list the install guide below does by hand: install the dependencies, compile the assets (php bin/console asset-map:compile), migrate, sync the catalogue (registry:sync), serve public/, and keep the queue worker running.

Requirements

  • PHP 8.4 or newer with the ctype, iconv, intl and pdo_pgsql extensions, and Composer.
  • The Symfony CLI, which serves the project and hands it the addresses of the services below.
  • Docker with Compose, for the services the project talks to: PostgreSQL with PostGIS, the Mercure hub and a mail catcher. compose.yaml runs all three, on Linux, macOS or Windows, on amd64 or arm64. A PostgreSQL with PostGIS of your own works instead of Docker; see step 2.

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-unr
cd uhifadhi-unr

Use any name you like in place of uhifadhi-unr; 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. Start the services

docker compose up -d --wait

That starts three containers, defined in compose.yaml and compose.override.yaml:

Service What it is
database PostgreSQL 17 with PostGIS (ghcr.io/utafitilabs/postgis), its data kept in a Docker volume
mercure the Mercure hub the live plates move over
mailer Mailpit, which catches every message the project sends

Each is published on a free port of the machine, and the Symfony CLI reads those ports from Docker: run every command through it — symfony console … rather than php bin/console … — and DATABASE_URL, MERCURE_URL, MERCURE_PUBLIC_URL and MAILER_DSN are set for you, with nothing written into .env. docker compose port mailer 8025 names the address where Mailpit shows the mail.

--wait returns once every service is healthy, and names the one that is not: without it, a database that stops a second after starting (a full Docker disk, say) goes unnoticed, and the next step quietly reaches for 127.0.0.1:5432 from .env instead — on a machine that runs a PostgreSQL of its own, a different database.

docker compose down stops them and keeps the data; docker compose down -v deletes the database too.

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. Without a Mercure hub the project runs as well: MERCURE_URL is empty in .env, every page answers, and every live plate is drawn once.

3. Run the migrations

The core ships the versions that create its own tables, so there is nothing to generate: you run them. First check that the Symfony CLI found the services: its DATABASE_URL must name the port Docker published, never 5432:

symfony var:export --multiline

Then:

symfony console cache:clear --no-warmup
symfony console doctrine:migrations:migrate
symfony console registry:sync
symfony 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. The answer comes in red, and it is the right one: Doctrine reports "nothing to write" as an error and exits non-zero, so a fresh installation prints

[critical] Error thrown while running command "doctrine:migrations:diff". Message: "No changes detected in your mapping information."

That line is the check passing, not failing.

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:

symfony 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 step of deploying, and whatever builds the release runs it.

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:

symfony 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" | symfony console team:user:create you@example.org Ada Mwangi --tier=super-admin

5. Serve it

symfony server:start -d

Open the address it prints — https://127.0.0.1:8000 once symfony server:ca:install has given the machine its local certificate authority, http://127.0.0.1:8000 before — and sign in as the administrator from step 4. The Mercure hub accepts subscriptions from exactly those two addresses (cors_origins in compose.yaml); serve on another and add it there.

The same command starts the workers .symfony.local.yaml declares — the services of step 2 and the queue worker of step 6 — and symfony server:status lists them. symfony server:stop stops them all.

Served any other way — php -S 127.0.0.1:8000 -t public, say — the project reads only .env and .env.local, so write the four addresses from symfony var:export --multiline into .env.local first.

6. The worker

Nothing to run here if you followed step 5. symfony server:start has already started the one worker an installation needs, because .symfony.local.yaml declares it; symfony server:status lists it. It is the queue worker, consuming two transports:

Transport What runs on it
async work a request hands over instead of doing: every message that implements the core's queue marker, Uhifadhi\Contracts\Queue\AsyncMessageInterface — filling a module's history once it is switched on, for one
scheduler_default the recurring tasks of the default schedule: the core recomputes the figures of the periods still open, hourly from 06:00 to 20:00 and once at 02:00, and an installed module adds its own

Under symfony server:start it runs beside the web server and is started again when the code or the installed packages change. Only if you serve the project some other way do you start it yourself, in a terminal of its own:

symfony console messenger:consume async scheduler_default -vv

Until it runs, handed-over work waits in the database — async and failed are queues in the messenger_messages table, on MESSENGER_TRANSPORT_DSN — and nothing is lost; a message that fails every retry lands in failed, where symfony console messenger:failed:show lists it.

Wherever the project is hosted, the worker is a process kept running beside the web server. After the code changes, php bin/console messenger:stop-workers lets each worker finish its message and stop, so it is started again on the new code.

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
symfony console cache:clear --no-warmup
symfony console doctrine:migrations:migrate
symfony console registry:sync
symfony 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:

symfony console doctrine:migrations:diff --namespace=DoctrineMigrations

It answers in red — [critical] … No changes detected in your mapping information. — and that is the answer you want: the module brought every table it needs, and the installation's own migrations/ has nothing to add.

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 seed content and the command that loads it, fixtures:seed; every module's development commands in one place composer require --dev uhifadhi/devkit-module · devkit-module README

8. Seed the installation

To look around, or to build against realistic records, devkit seeds an invented organization: Uhifadhi Nature Reserves, with two game reserves, their zones and their posts, the people posted at them, and a month of whatever each installed module records — patrols, incidents, watches. It is development content, installed with --dev so a production build never carries it:

composer require --dev uhifadhi/devkit-module
symfony php -d memory_limit=2G bin/console fixtures:seed --no-debug

Seed after the modules of step 7 are installed: each module seeds its own records onto the reserves. A second run adds nothing. The organization's name is the shell's brand_name in config/packages/shell.yaml; set it to Uhifadhi Nature Reserves for the seeded installation to read as one. Sign in as the administrator from step 4 — the seeded people have no passwords — and switch the modules on for the reserves from each reserve's Modules section.

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.