An admin creates Events (content, thumbnail, details, tags, category, expiry time). Users subscribe to events. When the admin posts a Story (update) inside an event, every subscriber is notified automatically across three channels:
- In-app, real-time — Redis pub/sub → gateway SSE → browser notification bell
- Email — SMTP (Mailpit in dev, SES-compatible in prod)
- Web push — VAPID browser push
Next.js web ──REST──▶ api-gateway ──gRPC──▶ user-service (users schema)
│ ──gRPC──▶ event-service (events schema: subscriptions + outbox)
│ ──gRPC──▶ notification-service (notifications schema)
└──SSE (Redis pub/sub) ◀── notification-service
event-service: story write + outbox row in ONE transaction
└▶ outbox-publisher ─▶ RabbitMQ (topic `events`, key `story.created`)
└▶ fanout-worker ─▶ `notification.requested` per subscriber
└▶ notification consumer ─▶ persist in-app notif + dispatch:
• in-app → Redis pub/sub → gateway SSE → browser
• email → SMTP (Mailpit dev / SES prod)
• webpush → VAPID
Reliability properties:
- Transactional outbox — a story and its
story.createdoutbox row commit atomically; theoutbox-publisherworker delivers at-least-once to RabbitMQ. - Idempotent consumers — every
notification.requestedmessage carries a deterministic id (story-{id}-user-{id}); the notification consumer dedupes on it, so redeliveries never create duplicate notifications. - Dead-letter queues — failing messages land in
<queue>.dlqinstead of poisoning the queue. - Expiry scheduler — a k8s CronJob marks events past
expired_timeasexpired(hidden from public listings) and emitsevent.expired.
| Path | What it is |
|---|---|
services/api-gateway |
REST gateway (:9000): auth, events, uploads, notifications, SSE |
services/user-service |
gRPC (:50051): users, bcrypt credentials, roles |
services/event-service |
gRPC (:50052): events/categories/tags/stories/subscriptions/outbox + workers |
services/notification-service |
gRPC (:50053): notifications + push subscriptions + consumer |
shared/ |
Router, HTTP envelope, Validator, DB, RabbitMQ/Redis buses, generated proto stubs |
protos/ |
user.proto, event.proto, notification.proto |
migrations/ |
0000_create_schemas.sql + one directory per service (schema-per-service) |
infra/dev |
Dockerfiles + k8s manifests (Tilt-driven) |
web/ |
Next.js frontend (browse/subscribe, notification bell, admin dashboard) |
PHP 8.4+, Composer, protoc + grpc_php_plugin (for proto regeneration),
Docker + a local k8s cluster + Tilt, Node 22+ for the frontend.
The grpc/protobuf PHP extensions are only needed inside the containers — the
shared base image (infra/dev/docker/php-base.dockerfile) builds them.
cp .env.example .env.local # adjust if needed
source .env.local
tilt up # mysql, rabbitmq, redis, minio, mailpit + all services & workers
make db-migrate # creates schemas (users/events/notifications) + tables
cd web && npm install && cp .env.example .env.local && npm run devDev endpoints once Tilt is green:
| URL | Service |
|---|---|
| http://localhost:9000 | API gateway |
| http://localhost:3000 | Next.js frontend |
| http://localhost:8025 | Mailpit inbox |
| http://localhost:15672 | RabbitMQ management (guest/guest) |
| http://localhost:9901 | MinIO console |
Register through the UI (or POST /api/auth/register), then promote:
mysql -h 127.0.0.1 -u root -p'root_password_123!' \
-e "UPDATE users.users SET role='admin' WHERE email='you@example.com'"Log out and back in so the JWT picks up the new role.
npx web-push generate-vapid-keysPut the keys in infra/dev/k8s/app-secret.yaml (VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY)
and in web/.env.local (NEXT_PUBLIC_VAPID_PUBLIC_KEY), then restart Tilt.
Public: POST /api/auth/register|login, GET /api/auth/me, GET /api/events[/{id}[/stories]],
GET /api/categories, GET /api/tags.
Authenticated: POST|DELETE /api/events/{id}/subscribe, GET /api/me/subscriptions,
GET /api/me/notifications, POST /api/me/notifications/{id}/read, POST /api/me/notifications/read-all,
POST /api/me/push-subscriptions, GET /api/notifications/stream (SSE; accepts ?token=).
Admin: GET|POST /api/admin/events, GET|PUT|DELETE /api/admin/events/{id},
POST /api/admin/events/{id}/stories, POST|DELETE /api/admin/categories[/{id}],
POST|DELETE /api/admin/tags[/{id}], POST /api/admin/uploads (presigned MinIO/S3 upload).
Each service has a Pest suite (services/<name>/tests). DB-backed tests provision
isolated <schema>_test databases from the migration files and skip automatically
when MySQL is unreachable.
make test # all suites
cd services/event-service && ./vendor/bin/pest # one suiteCI (.github/workflows/ci.yml) runs every suite against a MySQL service container
plus a frontend lint + build.
make generate-proto # emits PHP classes into shared/Proto/*- Register two users; promote one to admin (see above).
- As admin: create a category/tags, create a published event with a thumbnail.
- As the second user: subscribe to the event; keep the site open (bell connects via SSE).
- As admin: post a story on the event.
- Observe: outbox row → RabbitMQ → notification row, the bell updates live, the email appears in Mailpit (http://localhost:8025), and web push fires if configured.
- Redeliver the same message (RabbitMQ management → move from queue) → no duplicate notification.