From 27b078f7dfa56c0711829b436c80638c166b13f2 Mon Sep 17 00:00:00 2001 From: Oleg Timchenko Date: Thu, 25 Jun 2026 18:21:48 +0300 Subject: [PATCH 1/5] added ci config --- .github/workflows/ci.yml | 48 +++++++++++++++++++ .../app/Actions/Stats/BuildStatsSummary.php | 2 +- apps/api/phpstan.neon | 3 -- apps/api/test-phpstan.neon | 13 +++++ 4 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 apps/api/test-phpstan.neon diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d28a02a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + push: + pull_request: + +jobs: + api: + name: PHP 8.3 — Pint · PHPStan · Pest + runs-on: ubuntu-latest + + defaults: + run: + working-directory: apps/api + + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP 8.3 + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: pdo_sqlite, pdo_pgsql + coverage: none + + - name: Cache Composer packages + uses: actions/cache@v4 + with: + path: apps/api/vendor + key: ${{ runner.os }}-composer-${{ hashFiles('apps/api/composer.lock') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist --optimize-autoloader + + - name: Prepare environment + run: | + cp .env.example .env + php artisan key:generate + + - name: Check code style (Pint) + run: ./vendor/bin/pint --test + + - name: Static analysis (PHPStan level 8) + run: ./vendor/bin/phpstan analyse + + - name: Run test suite (Pest) + run: php artisan test diff --git a/apps/api/app/Actions/Stats/BuildStatsSummary.php b/apps/api/app/Actions/Stats/BuildStatsSummary.php index 6ca7a96..51d0e2f 100644 --- a/apps/api/app/Actions/Stats/BuildStatsSummary.php +++ b/apps/api/app/Actions/Stats/BuildStatsSummary.php @@ -11,7 +11,7 @@ final class BuildStatsSummary { /** - * @return array{monthly_total: float, yearly_total: float, by_category: list>} + * @return array{monthly_total: float, yearly_total: float, by_category: array>} */ public function handle(User $user): array { diff --git a/apps/api/phpstan.neon b/apps/api/phpstan.neon index 44fe59e..449041b 100644 --- a/apps/api/phpstan.neon +++ b/apps/api/phpstan.neon @@ -10,6 +10,3 @@ parameters: - message: '#uses generic trait .* but does not specify its types#' path: app/Models/* - - - message: '#Call to an undefined method Pest#' - path: tests/* diff --git a/apps/api/test-phpstan.neon b/apps/api/test-phpstan.neon new file mode 100644 index 0000000..d95b6fa --- /dev/null +++ b/apps/api/test-phpstan.neon @@ -0,0 +1,13 @@ +includes: + - vendor/larastan/larastan/extension.neon + +parameters: + paths: + - app + - database/seeders + level: 8 + phpMemoryLimit: 512M + ignoreErrors: + - + message: '#uses generic trait .* but does not specify its types#' + path: app/Models/* From cee87d60331c73462015bfc3021422e5d053daed Mon Sep 17 00:00:00 2001 From: Oleg Timchenko Date: Thu, 25 Jun 2026 18:26:44 +0300 Subject: [PATCH 2/5] fix deploy config --- .github/workflows/ci.yml | 6 +++--- apps/api/composer.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d28a02a..1d2ca56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,7 @@ on: jobs: api: - name: PHP 8.3 — Pint · PHPStan · Pest + name: PHP 8.4 — Pint · PHPStan · Pest runs-on: ubuntu-latest defaults: @@ -16,10 +16,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up PHP 8.3 + - name: Set up PHP 8.4 uses: shivammathur/setup-php@v2 with: - php-version: '8.3' + php-version: '8.4' extensions: pdo_sqlite, pdo_pgsql coverage: none diff --git a/apps/api/composer.json b/apps/api/composer.json index 8faa0a3..8758843 100644 --- a/apps/api/composer.json +++ b/apps/api/composer.json @@ -6,7 +6,7 @@ "keywords": ["laravel", "framework"], "license": "MIT", "require": { - "php": "^8.3", + "php": "^8.4", "laravel/framework": "^13.8", "laravel/sanctum": "^4.0", "laravel/tinker": "^3.0" From 108f3630ff63b9b8fcdce983e28b47194504fe89 Mon Sep 17 00:00:00 2001 From: Oleg Timchenko Date: Thu, 25 Jun 2026 18:35:04 +0300 Subject: [PATCH 3/5] fix tests --- .github/workflows/ci.yml | 2 +- Makefile | 2 +- apps/api/app/Models/Subscription.php | 9 ++++++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d2ca56..d8d9083 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,7 @@ jobs: run: ./vendor/bin/pint --test - name: Static analysis (PHPStan level 8) - run: ./vendor/bin/phpstan analyse + run: ./vendor/bin/phpstan analyse --memory-limit=512M - name: Run test suite (Pest) run: php artisan test diff --git a/Makefile b/Makefile index 297ef84..53f4d32 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,7 @@ test: docker compose exec api php artisan test stan: - docker compose exec api ./vendor/bin/phpstan analyse + docker compose exec api ./vendor/bin/phpstan analyse --memory-limit=512M pint: docker compose exec api ./vendor/bin/pint diff --git a/apps/api/app/Models/Subscription.php b/apps/api/app/Models/Subscription.php index 29520d6..f00f8a4 100644 --- a/apps/api/app/Models/Subscription.php +++ b/apps/api/app/Models/Subscription.php @@ -7,6 +7,7 @@ use App\Enums\BillingCycle; use App\Enums\SubscriptionStatus; use Database\Factories\SubscriptionFactory; +use Illuminate\Database\Connection; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -131,8 +132,14 @@ public function scopeDueForRenewal(Builder $query): Builder */ public function scopeDueForReminder(Builder $query): Builder { + /** @var Connection $connection */ + $connection = $query->getConnection(); + $upper = $connection->getDriverName() === 'sqlite' + ? "date('now', '+' || notify_days_before || ' days')" + : "CURRENT_DATE + (notify_days_before * INTERVAL '1 day')"; + return $query->whereRaw( - "next_billing_date BETWEEN CURRENT_DATE AND CURRENT_DATE + (notify_days_before * INTERVAL '1 day')", + "next_billing_date BETWEEN CURRENT_DATE AND {$upper}", ); } From bd9dcf5460276f6ac4fdec97d428e98b2d06dad2 Mon Sep 17 00:00:00 2001 From: Oleg Timchenko Date: Thu, 25 Jun 2026 18:48:23 +0300 Subject: [PATCH 4/5] fix tests --- apps/api/app/Models/Subscription.php | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/apps/api/app/Models/Subscription.php b/apps/api/app/Models/Subscription.php index f00f8a4..21477fc 100644 --- a/apps/api/app/Models/Subscription.php +++ b/apps/api/app/Models/Subscription.php @@ -134,13 +134,23 @@ public function scopeDueForReminder(Builder $query): Builder { /** @var Connection $connection */ $connection = $query->getConnection(); - $upper = $connection->getDriverName() === 'sqlite' - ? "date('now', '+' || notify_days_before || ' days')" - : "CURRENT_DATE + (notify_days_before * INTERVAL '1 day')"; - - return $query->whereRaw( - "next_billing_date BETWEEN CURRENT_DATE AND {$upper}", - ); + $today = now()->toDateString(); + + if ($connection->getDriverName() === 'sqlite') { + return $query + ->where('next_billing_date', '>=', $today) + ->whereRaw( + "date(next_billing_date, '-' || notify_days_before || ' days') <= ?", + [$today], + ); + } + + return $query + ->where('next_billing_date', '>=', $today) + ->whereRaw( + 'next_billing_date - notify_days_before <= CAST(? AS date)', + [$today], + ); } /** @return Attribute */ From c8a14b3e2a5e5ab74152237b2230a1603f56cc62 Mon Sep 17 00:00:00 2001 From: Oleg Timchenko Date: Thu, 25 Jun 2026 20:22:53 +0300 Subject: [PATCH 5/5] readme updated --- README.md | 239 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 143 insertions(+), 96 deletions(-) diff --git a/README.md b/README.md index b87f182..8f829eb 100644 --- a/README.md +++ b/README.md @@ -1,137 +1,184 @@ # Subscription Tracker -Personal subscription tracker — a portfolio project demonstrating idiomatic Laravel 13 -on a realistic domain. The backend is the focus: Action-class architecture, Sanctum token -auth, owner-only Policies, and a Redis-backed queue pipeline for async renewal processing. +[![CI](https://github.com/evo9/subscription-trancker/actions/workflows/ci.yml/badge.svg)](https://github.com/evo9/subscription-trancker/actions/workflows/ci.yml) +![PHP](https://img.shields.io/badge/PHP-8.4-777BB4?style=flat&logo=php&logoColor=white) +![Laravel](https://img.shields.io/badge/Laravel-13-FF2D20?style=flat&logo=laravel&logoColor=white) +![Next.js](https://img.shields.io/badge/Next.js-16-000000?style=flat&logo=nextdotjs&logoColor=white) +![TypeScript](https://img.shields.io/badge/TypeScript-5-3178C6?style=flat&logo=typescript&logoColor=white) +![React](https://img.shields.io/badge/React-19-61DAFB?style=flat&logo=react&logoColor=black) +![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-4169E1?style=flat&logo=postgresql&logoColor=white) +![Redis](https://img.shields.io/badge/Redis-7-DC382D?style=flat&logo=redis&logoColor=white) +![Docker](https://img.shields.io/badge/Docker-Compose-2496ED?style=flat&logo=docker&logoColor=white) +![TailwindCSS](https://img.shields.io/badge/Tailwind-CSS-06B6D4?style=flat&logo=tailwindcss&logoColor=white) +![Pest](https://img.shields.io/badge/Pest-3-brightgreen?style=flat) -## Stack +A portfolio project demonstrating idiomatic Laravel 13 on a realistic, self-contained domain. The goal is not full-stack breadth but depth: every framework feature used here grows naturally from the problem rather than being bolted on. The centrepiece is the **Scheduler → Queued Job → Notification** pipeline — the exact async pattern interviewers ask about, made concrete by a genuine business need ("remind the user before a subscription renews"). -| Layer | Technology | -|-------|------------| -| API | PHP 8.3, Laravel 13, Sanctum, PostgreSQL 16, Redis | -| Web | Next.js 16 (App Router), React 19, TypeScript, TanStack Query | -| Testing | Pest 3, Larastan/PHPStan level 8, Laravel Pint | -| Infra | Docker Compose, nginx, GitHub Actions | +--- -## Project Layout +## Quickstart -``` -apps/api/ Laravel 13 REST API (php-fpm behind nginx) -apps/web/ Next.js 16 SPA client -docker/ Dockerfiles + nginx config -docs/ specs/ and tasks/ +**Prerequisite:** Docker and Docker Compose only — PHP, Node, Postgres, and Redis all run in containers. + +```bash +cp .env.example .env +make install ``` -## Architecture +`make install` builds images, installs Composer and npm dependencies, generates the app key, runs migrations, and seeds demo data. + +| Service | URL | +|---------|-----| +| Web (Next.js) | http://localhost | +| API (via nginx) | http://localhost/api | +| pgAdmin | http://localhost:5050 | -The API follows a thin-controller pattern: +Demo credentials: `demo@example.com` / `password` -``` -Route → Controller (Form Request + Policy) → Action → API Resource -``` +--- -Domain logic lives in Action classes under `app/Actions/`, typed PHP 8.3 Enums -(`BillingCycle`, `SubscriptionStatus`), Eloquent accessors, and query scopes — not in -controllers or a separate service layer. +## Laravel Features -The async showcase: a daily scheduler command dispatches jobs to a Redis queue, which -processes renewals and sends `RenewalReminder` notifications via the `mail` (logged in -dev) and `database` channels. The `queue` and `scheduler` containers reuse the `api` -image with a different command. +The table below maps each showcased feature to its location in the codebase. -## Prerequisites +| Feature | Location | +|---------|----------| +| Eloquent relationships | [`apps/api/app/Models/`](apps/api/app/Models/) — `User→Subscription/Category`, `Subscription→Payment` | +| Eloquent casts | [`apps/api/app/Models/Subscription.php`](apps/api/app/Models/Subscription.php) — `billing_cycle`/`status` → Enum, `price` → decimal, dates → `date` | +| Accessors (computed fields) | [`apps/api/app/Models/Subscription.php`](apps/api/app/Models/Subscription.php) — `monthlyCost`, `yearlyCost` | +| Query scopes | [`apps/api/app/Models/Subscription.php`](apps/api/app/Models/Subscription.php) — `active()`, `dueWithin($days)`, `forUser($user)`, `dueForReminder()` | +| Soft deletes | `subscriptions.deleted_at` — transparent to all queries via `SoftDeletes` | +| Factories + Seeders | [`apps/api/database/factories/`](apps/api/database/factories/), [`apps/api/database/seeders/`](apps/api/database/seeders/) | +| Scheduler | [`apps/api/routes/console.php`](apps/api/routes/console.php) — two daily commands | +| Artisan commands | [`apps/api/app/Console/Commands/`](apps/api/app/Console/Commands/) — `app:process-renewals`, `app:send-renewal-reminders` | +| Queued Jobs | [`apps/api/app/Jobs/SendRenewalReminderJob.php`](apps/api/app/Jobs/SendRenewalReminderJob.php) — dispatched to Redis | +| Notifications | [`apps/api/app/Notifications/RenewalReminder.php`](apps/api/app/Notifications/RenewalReminder.php) — `mail` + `database` channels | +| Events / Observers | [`apps/api/app/Events/`](apps/api/app/Events/), [`apps/api/app/Observers/`](apps/api/app/Observers/) | +| Sanctum token auth | Bearer token for the Next.js SPA — register, login, logout | +| Policies | [`apps/api/app/Policies/`](apps/api/app/Policies/) — `SubscriptionPolicy`, `CategoryPolicy` (owner-only) | +| Form Requests | [`apps/api/app/Http/Requests/`](apps/api/app/Http/Requests/) — validation on every mutating endpoint | +| API Resources | [`apps/api/app/Http/Resources/`](apps/api/app/Http/Resources/) — typed response shaping | +| Tests (Pest 3) | [`apps/api/tests/`](apps/api/tests/) — Feature + Unit, `Queue::fake()`, `Notification::fake()` | -- Docker and Docker Compose +--- -Everything else (PHP, Node, Postgres, Redis) runs inside containers. +## Architecture -## Setup +### Action-class pattern -```bash -cp .env.example .env -make install # build images, start containers, install deps, migrate + seed +Controllers are intentionally thin. Every operation — including non-trivial reads — lives in a dedicated Action class: + +``` +Route + └── Controller (Form Request + Policy check) + └── Action (all domain logic) + └── API Resource (response shaping) ``` -Or step by step: +Actions live in `apps/api/app/Actions/{Domain}/` and follow a simple `handle()` contract. There is no separate Service or Repository layer — Eloquent models are used directly inside Actions. + +A module layout (`app/Modules/Subscriptions/...`) was considered and rejected: the project is compact enough that modules would add navigation overhead without meaningful boundary enforcement. The Action namespace itself provides the separation that matters. + +### Async pipeline + +The Scheduler → Queue → Notification chain is the core showcase: -```bash -cp .env.example .env -docker compose up -d -docker compose exec api composer install -docker compose exec api php artisan key:generate -docker compose exec api php artisan migrate --seed +``` +Scheduler (daily) + ├── app:process-renewals + │ └── ProcessDueRenewals Action + │ ├── Creates Payment + advances next_billing_date (BillingCycle::advance) + │ ├── Fires SubscriptionRenewed event + │ └── Idempotent — re-running the same day creates no duplicate Payment + │ + └── app:send-renewal-reminders + └── SendDueReminders Action + └── Dispatches SendRenewalReminderJob → Redis queue + └── Job sends RenewalReminder notification + ├── mail channel (logged in dev, real SMTP in prod) + └── database channel (readable via /api/notifications) ``` -| Service | URL | -|---------|-----| -| API (via nginx) | http://localhost/api | -| Web | http://localhost | -| pgAdmin | http://localhost:5050 | +The `queue` and `scheduler` containers are separate Docker services that reuse the `api` image with a different command — no extra image to build or maintain. + +### Domain value objects + +`BillingCycle` and `SubscriptionStatus` are backed PHP enums with methods (`advance()`, `perYear()`) that encapsulate date arithmetic and cost normalization. This keeps model accessors and Action logic free of `switch` statements. + +--- ## Common Commands ```bash -# Start / stop -make up -make down - -# Database -make migrate # migrate --seed -make fresh # migrate:fresh --seed +make up # start all containers +make down # stop all containers -# Testing -make test # Pest -make stan # Larastan / PHPStan level 8 +make migrate # php artisan migrate --seed +make fresh # php artisan migrate:fresh --seed -# Code style -make pint # fix with Laravel Pint -docker compose exec api ./vendor/bin/pint --test # check only (CI) +make test # Pest test suite +make stan # PHPStan level 8 +make pint # fix style with Laravel Pint -# Shells -make api-sh -make web-sh +make api-sh # shell into the api container +make web-sh # shell into the web container ``` -## API Endpoints +--- -All routes are prefixed `/api`. Everything except `register` and `login` requires a -Sanctum bearer token. +## Project Layout ``` -POST /api/register -POST /api/login -POST /api/logout - -GET /api/subscriptions -POST /api/subscriptions -GET /api/subscriptions/{id} -PUT /api/subscriptions/{id} -DELETE /api/subscriptions/{id} +apps/api/ Laravel 13 REST API (php-fpm behind nginx) + app/Actions/ one class per operation + app/Console/Commands/ app:process-renewals, app:send-renewal-reminders + app/Events/ SubscriptionRenewed, … + app/Http/ + Controllers/ thin — validate, authorize, call Action, return Resource + Requests/ Form Request validation + Resources/ API response shaping + app/Jobs/ SendRenewalReminderJob + app/Models/ User, Subscription, Category, Payment + app/Notifications/ RenewalReminder (mail + database) + app/Observers/ model observers + app/Policies/ SubscriptionPolicy, CategoryPolicy + database/ + migrations/ + factories/ + seeders/ + routes/ + api.php REST routes + console.php scheduler definitions + tests/ + Feature/ HTTP, queue, notification, command tests + Unit/ BillingCycle, cost normalization + +apps/web/ Next.js 16 SPA (App Router, React 19, TypeScript) + src/app/(auth)/ login, register + src/app/(app)/ dashboard, subscriptions, notifications + src/components/ charts (Recharts), subscription forms, UI primitives + src/lib/api.ts axios instance + Sanctum bearer interceptor + src/lib/queries.ts all server state via TanStack Query + src/types/api.ts API response types + +docker/ Dockerfiles + nginx config +docs/ specs/ (source of truth) + tasks/ (work breakdown) +``` -POST /api/subscriptions/{id}/pause -POST /api/subscriptions/{id}/resume +--- -GET /api/subscriptions/{id}/payments -``` +## Out of Scope -`SubscriptionPolicy` enforces owner-only access on every subscription route. +These are deliberate omissions, not gaps. The project is sized to demonstrate patterns clearly without fake complexity. -## Features +- **Payment processing** — `Payment` is an accounting record, not a transaction; no Stripe or equivalent +- **Multi-currency conversion** — `currency` is stored as a label; all calculations assume a single currency +- **Roles, teams, sharing** — single-user; Policies demonstrate authorization without needing multi-tenancy +- **SSR / ISR on the frontend** — the Next.js client is a thin API consumer; no server components beyond routing +- **Expense splitting** — out of domain -- Sanctum token authentication (register, login, logout) -- Subscription CRUD with `SubscriptionPolicy` (owner-only) -- Subscription lifecycle: `active → paused → active → cancelled` -- `BillingCycle` enum (`weekly`, `monthly`, `quarterly`, `yearly`) with - `advance()` (date arithmetic) and `perYear()` (normalization) methods -- `SubscriptionStatus` enum enforced at the model and Form Request layers -- Payment history per subscription -- Soft deletes on subscriptions -- API Resources for all responses +--- -## Development Notes +## Screenshots -- `declare(strict_types=1)` in every PHP file; full type hints throughout -- PHPStan level 8 must pass clean before merging -- Pint (PSR-12 preset) is enforced in CI via `pint --test` -- Tests use a separate Postgres schema with `RefreshDatabase` -- Out of scope: real payment integration, multi-currency conversion, sharing/roles +