Church App — Local Development Environment
PHP and Yarn run on the host. Everything else in Docker Compose. One
docker compose upand you’re ready to code.
Philosophy
- PHP on host: Faster execution, better IDE integration (PHPStan, Xdebug, autocompletion). No container file sync overhead. Symfony CLI provides a local web server with TLS.
- Yarn on host: Same reasoning. Hot reload, fast builds, native file watching. Expo CLI needs direct host access for device connections.
- Everything else in Docker: PostgreSQL, Redis, Zitadel, Mailpit (local email testing), MinIO (S3-compatible, replaces R2 locally). Consistent versions, isolated data, disposable.
Prerequisites
REQUIRED ON HOST:
├── PHP 8.3+ with extensions:
│ ├── pdo_pgsql, intl, mbstring, xml, curl, zip, gd, redis
│ ├── Composer 2.x
│ └── Symfony CLI (for local web server + TLS)
│
├── Node.js 20 LTS + Yarn (or npm)
│ ├── Expo CLI (npx expo)
│ └── EAS CLI (for builds, optional locally)
│
├── Docker + Docker Compose v2
│
└── OPTIONAL:
├── Xdebug 3.x (PHP debugging)
├── make (for Makefile shortcuts)
└── mkcert (for local TLS certificates if not using Symfony CLI)
Docker Compose Services
# docker-compose.yml
services:
# ──────────────────────────────────────
# DATABASE
# ──────────────────────────────────────
postgres:
image: postgres:16-alpine
container_name: churchapp-postgres
ports:
- "5432:5432"
environment:
POSTGRES_DB: churchapp
POSTGRES_USER: churchapp
POSTGRES_PASSWORD: churchapp_dev
volumes:
- postgres_data:/var/lib/postgresql/data
- ./infrastructure/docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U churchapp"]
interval: 5s
timeout: 3s
retries: 5
# ──────────────────────────────────────
# CACHE / MESSAGE QUEUE / EVENT BUS
# ──────────────────────────────────────
redis:
image: redis:7-alpine
container_name: churchapp-redis
ports:
- "6379:6379"
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
# ──────────────────────────────────────
# IDENTITY PROVIDER
# ──────────────────────────────────────
zitadel:
image: ghcr.io/zitadel/zitadel:latest
container_name: churchapp-zitadel
command: start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled
ports:
- "8080:8080"
environment:
ZITADEL_DATABASE_POSTGRES_HOST: postgres
ZITADEL_DATABASE_POSTGRES_PORT: 5432
ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel
ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel
ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: zitadel_dev
ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: churchapp
ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: churchapp_dev
ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable
ZITADEL_EXTERNALSECURE: "false"
ZITADEL_EXTERNALPORT: 8080
ZITADEL_EXTERNALDOMAIN: localhost
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: admin@localhost
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: Admin123!
depends_on:
postgres:
condition: service_healthy
# ──────────────────────────────────────
# LOCAL EMAIL TESTING (replaces Mailgun in dev)
# ──────────────────────────────────────
mailpit:
image: axllent/mailpit:latest
container_name: churchapp-mailpit
ports:
- "1025:1025" # SMTP
- "8025:8025" # Web UI (view sent emails)
environment:
MP_SMTP_AUTH_ACCEPT_ANY: "true"
MP_SMTP_AUTH_ALLOW_INSECURE: "true"
# ──────────────────────────────────────
# S3-COMPATIBLE OBJECT STORAGE (replaces Cloudflare R2 in dev)
# ──────────────────────────────────────
minio:
image: minio/minio:latest
container_name: churchapp-minio
ports:
- "9000:9000" # API
- "9001:9001" # Console UI
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: churchapp
MINIO_ROOT_PASSWORD: churchapp_dev
volumes:
- minio_data:/data
# ──────────────────────────────────────
# DATABASE ADMIN (optional, useful for quick queries)
# ──────────────────────────────────────
adminer:
image: adminer:latest
container_name: churchapp-adminer
ports:
- "8081:8080"
depends_on:
- postgres
volumes:
postgres_data:
redis_data:
minio_data:
PostgreSQL Init Script
-- infrastructure/docker/postgres/init.sql
-- Creates the separate database for Zitadel and enables extensions
CREATE USER zitadel WITH PASSWORD 'zitadel_dev';
CREATE DATABASE zitadel OWNER zitadel;
-- Extensions for the main app database
\c churchapp;
CREATE EXTENSION IF NOT EXISTS ltree;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
Service Map
HOST MACHINE (your laptop)
│
├── PHP 8.3 (Symfony CLI)
│ ├── symfony serve → https://localhost:8000 (API + admin backend)
│ ├── Connects to: postgres:5432, redis:6379, mailpit:1025, minio:9000
│ ├── Symfony Messenger workers: symfony run -- php bin/console messenger:consume
│ └── Xdebug listens on: host port 9003
│
├── Yarn / Expo
│ ├── npx expo start → Expo dev server (mobile + web)
│ ├── Web: http://localhost:8081 (Expo Web / PWA dev)
│ ├── iOS: Expo Go or dev build on simulator
│ ├── Android: Expo Go or dev build on emulator
│ └── Connects to: Symfony API at https://localhost:8000
│
└── Docker Compose
├── postgres:5432 — main DB + Zitadel DB
├── redis:6379 — cache, message queue, event bus
├── zitadel:8080 — identity provider UI + API
├── mailpit:8025 — email testing UI (view all sent emails)
├── minio:9000/9001 — object storage (S3 API + web console)
└── adminer:8081 — database admin UI (optional)
Local URLs
| Service | URL | Purpose |
|---|---|---|
| Symfony API | https://localhost:8000 | Backend API + admin |
| Expo Web | http://localhost:8081 | React Native web dev |
| Zitadel | http://localhost:8080 | Identity provider console |
| Mailpit | http://localhost:8025 | View sent emails |
| MinIO Console | http://localhost:9001 | Object storage browser |
| Adminer | http://localhost:8081 | Database admin |
| Redis | localhost:6379 | (no UI, use redis-cli) |
Symfony Configuration (dev environment)
# .env.local (not committed, dev overrides)
DATABASE_URL="postgresql://churchapp:churchapp_dev@localhost:5432/churchapp?serverVersion=16&charset=utf8"
REDIS_URL="redis://localhost:6379"
MAILER_DSN="smtp://localhost:1025"
# Zitadel
ZITADEL_URL="http://localhost:8080"
ZITADEL_ISSUER="http://localhost:8080"
# MinIO (S3-compatible)
S3_ENDPOINT="http://localhost:9000"
S3_ACCESS_KEY="churchapp"
S3_SECRET_KEY="churchapp_dev"
S3_BUCKET="churchapp-media"
S3_REGION="us-east-1"
S3_USE_PATH_STYLE="true"
# App
APP_ENV=dev
APP_SECRET=dev_secret_change_in_production
Makefile (Developer Shortcuts)
# Makefile — top-level developer commands
.PHONY: up down restart logs db-reset db-migrate db-seed test lint ci worker
# ── Docker ──────────────────────────────
up:
docker compose up -d
@echo "Services started. Waiting for health checks..."
@sleep 3
@docker compose ps
down:
docker compose down
restart:
docker compose down && docker compose up -d
logs:
docker compose logs -f
# ── Database ────────────────────────────
db-migrate:
php bin/console doctrine:migrations:migrate --no-interaction
db-reset:
php bin/console doctrine:database:drop --force --if-exists
php bin/console doctrine:database:create
php bin/console doctrine:migrations:migrate --no-interaction
@echo "Database reset complete."
db-seed:
php bin/console app:seed:demo-data
@echo "Demo data seeded."
# ── Symfony ─────────────────────────────
serve:
symfony serve -d
worker:
symfony run -- php bin/console messenger:consume async audit history --time-limit=3600 -vv
# ── Frontend ────────────────────────────
expo:
cd frontend && npx expo start
expo-web:
cd frontend && npx expo start --web
# ── Quality ─────────────────────────────
lint:
vendor/bin/phpstan analyse --level=8
vendor/bin/deptrac analyse
cd frontend && npx eslint . && npx tsc --noEmit
test:
vendor/bin/phpunit
test-integration:
vendor/bin/phpunit --testsuite=integration
# ── Full CI check (run before pushing) ──
ci: lint test
@echo "All checks passed."
# ── MinIO bucket setup ──────────────────
minio-setup:
docker compose exec minio mc alias set local http://localhost:9000 churchapp churchapp_dev
docker compose exec minio mc mb local/churchapp-media --ignore-existing
@echo "MinIO bucket created."
# ── Complete local setup ────────────────
setup: up minio-setup db-reset db-migrate db-seed
@echo ""
@echo "Local environment ready!"
@echo " API: symfony serve"
@echo " Frontend: cd frontend && npx expo start"
@echo " Zitadel: http://localhost:8080"
@echo " Mailpit: http://localhost:8025"
@echo " MinIO: http://localhost:9001"
First-Time Setup
# 1. Clone the repo
git clone git@gitlab.com:segli/church-app.git
cd church-app
# 2. Install PHP dependencies
composer install
# 3. Install frontend dependencies
cd frontend && yarn install && cd ..
# 4. Copy environment config
cp .env .env.local
# Edit .env.local if needed (defaults should work)
# 5. Start all Docker services + setup DB + seed data
make setup
# 6. Start Symfony dev server
make serve
# 7. Start Expo (in a second terminal)
make expo
# 8. Open in browser
# API + Admin: https://localhost:8000
# Expo Web: http://localhost:8081
# Zitadel: http://localhost:8080 (admin@localhost / Admin123!)
# Mailpit: http://localhost:8025 (check emails here)
# MinIO: http://localhost:9001 (churchapp / churchapp_dev)
# You're ready to develop.
Working With the Environment
Daily workflow
# Morning: start everything
make up && make serve
# (in another terminal)
make expo
# Run Symfony Messenger workers (for async events, audit, history)
make worker
# Check sent emails (password resets, invites, etc.)
open http://localhost:8025
# Run quality checks before pushing
make ci
Reset everything
# Nuclear option: wipe all data, start fresh
docker compose down -v # removes volumes (all data!)
make setup # recreate everything
Run a single test
vendor/bin/phpunit tests/Unit/Module/Groups/Domain/Model/GroupTest.php
Database access
# Via psql
docker compose exec postgres psql -U churchapp -d churchapp
# Via Adminer UI
open http://localhost:8081
# Server: postgres, User: churchapp, Password: churchapp_dev, Database: churchapp
Check Redis
docker compose exec redis redis-cli
> KEYS *
> MONITOR # watch all commands in real-time
Environment Differences
| Concern | Local (dev) | Staging | Production |
|---|---|---|---|
| PHP | Host (Symfony CLI) | Docker | Docker |
| Database | Docker (postgres:16) | Hetzner CPX11 (Docker) | Hetzner CPX11 (Docker) |
| Redis | Docker | Docker | Docker |
| Identity | Docker (Zitadel) | Docker (Zitadel) | Docker (Zitadel) |
| Mailpit (catches all) | Mailgun (sandbox) | Mailgun (EU region) | |
| Storage | MinIO (local S3) | Cloudflare R2 | Cloudflare R2 |
| Web app | Expo dev server | Cloudflare Pages | Cloudflare Pages |
| TLS | Symfony CLI (self-signed) | Let’s Encrypt | Let’s Encrypt |
| Domain | localhost | staging.yourapp.com | yourapp.com |
The key principle: Symfony’s environment config (APP_ENV=dev) switches all service endpoints. The same code runs everywhere — only the .env values change.
Project Directory Structure
church-app/
├── .ai/ # Agent guidelines, ADRs, skills, templates
├── .github/
│ └── workflows/ # CI/CD pipelines
├── infrastructure/
│ ├── docker/
│ │ ├── postgres/
│ │ │ └── init.sql # DB init script
│ │ ├── php/
│ │ │ └── Dockerfile # Production PHP image
│ │ └── nginx/
│ │ └── nginx.conf # Production web server
│ ├── opentofu/ # IaC for staging + production
│ └── scripts/
│ ├── db-clone.sh
│ └── deploy.sh
├── src/ # Symfony application (DDD structure)
│ ├── Core/
│ ├── Module/
│ └── Pipeline/
├── config/ # Symfony config
├── migrations/ # Doctrine migrations
├── tests/ # PHPUnit tests
├── admin/ # Admin backend (Twig + Vue)
├── frontend/ # React Native (Expo) project
│ ├── app/ # Expo Router pages
│ ├── components/ # Shared components
│ ├── lib/ # API client, auth, storage
│ └── package.json
├── docker-compose.yml # Local dev services
├── Makefile # Developer shortcuts
├── composer.json
├── deptrac.yaml
├── phpunit.xml
└── .env # Default config (committed)
Status: Local Dev Environment — Ready for setup
Run make setup and start building.