Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: CI

on:
push:
pull_request:

jobs:
api:
name: PHP 8.4 — Pint · PHPStan · Pest
runs-on: ubuntu-latest

defaults:
run:
working-directory: apps/api

steps:
- uses: actions/checkout@v4

- name: Set up PHP 8.4
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
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 --memory-limit=512M

- name: Run test suite (Pest)
run: php artisan test
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
239 changes: 143 additions & 96 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
<!-- TODO: add dashboard, subscriptions, notifications screenshots -->
2 changes: 1 addition & 1 deletion apps/api/app/Actions/Stats/BuildStatsSummary.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
final class BuildStatsSummary
{
/**
* @return array{monthly_total: float, yearly_total: float, by_category: list<array<string, mixed>>}
* @return array{monthly_total: float, yearly_total: float, by_category: array<int, array<string, mixed>>}
*/
public function handle(User $user): array
{
Expand Down
23 changes: 20 additions & 3 deletions apps/api/app/Models/Subscription.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -131,9 +132,25 @@ public function scopeDueForRenewal(Builder $query): Builder
*/
public function scopeDueForReminder(Builder $query): Builder
{
return $query->whereRaw(
"next_billing_date BETWEEN CURRENT_DATE AND CURRENT_DATE + (notify_days_before * INTERVAL '1 day')",
);
/** @var Connection $connection */
$connection = $query->getConnection();
$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<float, never> */
Expand Down
2 changes: 1 addition & 1 deletion apps/api/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading