Search by

uhifadhi / skeleton

eemjema

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

Package info

github.com/utafitilabs/skeleton

Type:project

pkg:composer/uhifadhi/skeleton

Statistics

Installs: 43

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

This package is auto-updated.

Last update: 2026-09-25 20:40:48 UTC


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 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.