GiveLedger
A multi-tenant giving campaign tracker. Build the backend with PHP Hexagonal Architecture, the frontend with Vue 3 + TypeScript, containerize with Docker, and deploy to the cloud free tier.
What you will build — end to end
What You Will Build
GiveLedger lets churches manage giving campaigns. Each church is an independent tenant. Data is never shared across tenants — a campaign from one church is invisible to another.
Core features
Campaigns
Church staff create campaigns with a name, goal amount, currency, and deadline.
Donations
Donors record a donation toward a campaign. Each donation captures the donor name and amount.
Dashboard
Campaign detail shows progress toward the goal, top donors, and a filterable donation history.
Close Campaign
Staff close a campaign once the goal is reached. Closed campaigns reject new donations.
Domain rules
- A donation cannot be recorded against a closed campaign — enforced inside the aggregate
- A campaign cannot be closed before its goal amount is reached
- A campaign cannot be closed before its deadline — it must remain open for donors until then
- A donation amount must be greater than zero
- Campaign name must be between 3 and 100 characters
- All domain operations must be scoped to the requesting tenant — cross-tenant access is a hard error
Estimated effort: 5–8 days for a junior developer working at a steady pace. Scope is intentionally tight — depth of architecture is what is being evaluated, not breadth of features.
Multi-Tenant Architecture
Each church is a tenant identified by a TenantId. The tenant is resolved at the HTTP boundary — in Infrastructure — and flows inward as a primitive through Commands before becoming a TenantId Value Object in the Domain.
Tenant isolation strategy
- Shared database, scoped queries — a single MySQL database for all tenants. Every table with tenant-owned data carries a
tenant_idcolumn. - The
TenantIdis a Value Object in the Domain layer — it validates the UUID format and rejects blank values. - Every repository method that reads or writes tenant data receives a
TenantIdargument — no global state. - The Campaign aggregate validates that an operation comes from the same tenant that owns it — cross-tenant mutation throws a
TenantMismatchException. - The
TenantResolverlives in Infrastructure and resolves the tenant from the HTTP subdomain or theX-Tenant-IDheader.
Resolution flow
or header: X-Tenant-ID: <uuid>
tenantId: string as a plain primitiveRule: TenantId is constructed only inside the Domain layer. Infrastructure passes a raw string; the Handler or Aggregate is responsible for wrapping it in the Value Object.
Required Project Structure
Follow Tithely's exact directory conventions. No Laravel, no Symfony — plain PHP with PDO. The frontend is a separate Nuxt 3 app in the same repository under frontend/.
Backend — app/
app/
Application/
Campaign/
CreateCampaignCommand.php
CreateCampaignHandler.php
RecordDonationCommand.php
RecordDonationHandler.php
CloseCampaignCommand.php
CloseCampaignHandler.php
Domain/
Campaign/
Campaign.php ← Aggregate Root
CampaignId.php ← Value Object
CampaignName.php ← Value Object
CampaignStatus.php ← Value Object
Donation.php ← Entity
DonationId.php ← Value Object
DonorName.php ← Value Object
Shared/
TenantId.php ← Value Object (shared)
Money.php ← Value Object (shared)
TenantMismatchException.php
Campaign/
CampaignRepositoryInterface.php
Infrastructure/
Application/
HandlerBus.php
Domain/
CampaignRepository.php ← PDO implementation
HTTP/
Controller/Campaign/
CampaignController.php
Middleware/
TenantResolver.php
Query/
CampaignFinder.php
Api/Resource/Campaign/
CampaignResource.php
config/
handlers.php
routes.php
db/
migrations/
001_create_campaigns.sql
002_create_donations.sql
Frontend — frontend/
frontend/
pages/
index.vue ← campaign list
campaigns/
[id].vue ← campaign detail
[id]/
donate.vue ← donation form
new.vue ← create campaign
components/
CampaignCard.vue
DonationForm.vue
ProgressBar.vue
DonorList.vue
composables/
useCampaigns.ts
useDonation.ts
queries/
campaignQueries.ts
types/
campaign.ts
donation.ts
i18n/
en.json
Infrastructure — docker/
docker/
php/
Dockerfile
php.ini
nginx/
Dockerfile
nginx.conf
vue/
Dockerfile
docker-compose.yml
docker-compose.prod.yml
.env.example
docs/
deployment.md
Value Objects to Implement
Every Value Object must be immutable, validate its input on construction, and throw a typed domain exception on invalid data. No framework imports allowed in Domain/.
final class TenantId
{
private string $value;
private function __construct(string $value)
{
if (!preg_match(
'/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/',
$value
)) {
throw new \InvalidArgumentException(
"Invalid TenantId: {$value}"
);
}
$this->value = $value;
}
public static function of(string $value): self
{
return new self($value);
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
public function value(): string
{
return $this->value;
}
}
All Value Objects required
| Class | Validates |
|---|---|
TenantId | Valid UUID v4 format |
CampaignId | Valid UUID v4 format |
DonationId | Valid UUID v4 format |
CampaignName | 3–100 chars, non-blank |
DonorName | 2–80 chars, non-blank |
CampaignStatus | Enum: open | closed |
Money | Amount > 0, known currency code |
Money precision: store amounts as integers in cents (e.g. $10.00 = 1000). Never use floats for currency. The Money VO should expose a toCents(): int and a format(): string method.
Shared kernel: TenantId and Money live in Domain/Shared/ because multiple aggregates will use them as the project grows.
Campaign — Aggregate Root
Campaign is the aggregate root. All mutations go through its methods. The TenantId is stored on the aggregate and validated on every operation that receives one externally.
class Campaign
{
private CampaignId $id;
private TenantId $tenantId;
private CampaignName $name;
private Money $goal;
private Money $raised;
private CampaignStatus $status;
private \DateTimeImmutable $deadline;
/** @var Donation[] */
private array $donations = [];
private function __construct(
CampaignId $id,
TenantId $tenantId,
CampaignName $name,
Money $goal,
\DateTimeImmutable $deadline
) { /* ... */ }
public static function create(
CampaignId $id,
TenantId $tenantId,
CampaignName $name,
Money $goal,
\DateTimeImmutable $deadline
): self { /* ... */ }
public function recordDonation(
DonationId $id,
TenantId $tenantId,
DonorName $donor,
Money $amount
): void
{
$this->guardTenant($tenantId);
$this->guardOpen();
// accumulate $raised, append $donations
}
public function close(TenantId $tenantId): void
{
$this->guardTenant($tenantId);
$this->guardGoalReached();
$this->guardDeadlinePassed();
$this->status = CampaignStatus::closed();
}
private function guardTenant(TenantId $t): void
{
if (!$this->tenantId->equals($t)) {
throw new TenantMismatchException();
}
}
}
Invariant guards
| Guard method | Throws when |
|---|---|
guardTenant() | Incoming TenantId does not match the campaign's owner |
guardOpen() | Campaign status is closed |
guardGoalReached() | Total raised is less than the goal |
guardDeadlinePassed() | Current date is before the campaign deadline |
Donation entity
class Donation
{
public function __construct(
private readonly DonationId $id,
private readonly DonorName $donorName,
private readonly Money $amount,
private readonly \DateTimeImmutable $recordedAt
) {}
public function id(): DonationId { return $this->id; }
public function donorName(): DonorName { return $this->donorName; }
public function amount(): Money { return $this->amount; }
}
Key rule: Donation is never accessed directly by external code. It is created exclusively through Campaign::recordDonation().
Commands & Handlers
Commands are primitive-only DTOs — no objects, no Value Objects. The Handler constructs the domain objects, calls the aggregate, and persists through the repository interface.
final class RecordDonationCommand
{
public function __construct(
public readonly string $tenantId,
public readonly string $campaignId,
public readonly string $donationId,
public readonly string $donorName,
public readonly int $amountCents,
public readonly string $currency
) {}
}
final class RecordDonationHandler
{
public function __construct(
private CampaignRepositoryInterface $campaigns
) {}
public function handle(RecordDonationCommand $command): void
{
$tenantId = TenantId::of($command->tenantId);
$campaignId = CampaignId::of($command->campaignId);
$campaign = $this->campaigns->findById($campaignId, $tenantId);
if ($campaign === null) {
throw new CampaignNotFoundException($campaignId);
}
$campaign->recordDonation(
DonationId::of($command->donationId),
$tenantId,
DonorName::of($command->donorName),
Money::of($command->amountCents, $command->currency)
);
$this->campaigns->save($campaign);
}
}
All three commands to implement
| Command | Primitive fields |
|---|---|
CreateCampaignCommand |
tenantId, campaignId, name, goalCents, currency, deadline |
RecordDonationCommand |
tenantId, campaignId, donationId, donorName, amountCents, currency |
CloseCampaignCommand |
tenantId, campaignId |
handlers.php
return [
CreateCampaignCommand::class => CreateCampaignHandler::class,
RecordDonationCommand::class => RecordDonationHandler::class,
CloseCampaignCommand::class => CloseCampaignHandler::class,
];
Tenant flows through every Command as a plain string. The Handler is responsible for wrapping it into a TenantId Value Object — never the Controller.
Repository & Finder
The repository implements the domain interface and is the only place where SQL lives. The finder is a separate read-only class that returns plain arrays for API responses — never entities.
interface CampaignRepositoryInterface
{
public function findById(
CampaignId $id,
TenantId $tenantId
): ?Campaign;
public function save(Campaign $campaign): void;
}
final class CampaignRepository
implements CampaignRepositoryInterface
{
public function __construct(private \PDO $pdo) {}
public function findById(
CampaignId $id,
TenantId $tenantId
): ?Campaign
{
$stmt = $this->pdo->prepare(
'SELECT * FROM campaigns
WHERE id = :id
AND tenant_id = :tenant_id
LIMIT 1'
);
$stmt->execute([
'id' => $id->value(),
'tenant_id' => $tenantId->value(),
]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
return $row ? $this->hydrate($row) : null;
}
}
CampaignFinder — read path
final class CampaignFinder
{
public function __construct(private \PDO $pdo) {}
/** @return array> */
public function allForTenant(string $tenantId): array
{
$stmt = $this->pdo->prepare(
'SELECT
c.id, c.name, c.goal_cents,
c.currency, c.status, c.deadline,
COALESCE(SUM(d.amount_cents), 0) AS raised_cents
FROM campaigns c
LEFT JOIN donations d ON d.campaign_id = c.id
WHERE c.tenant_id = :tenant_id
GROUP BY c.id
ORDER BY c.created_at DESC'
);
$stmt->execute(['tenant_id' => $tenantId]);
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
}
Database schema
CREATE TABLE campaigns (
id CHAR(36) PRIMARY KEY,
tenant_id CHAR(36) NOT NULL,
name VARCHAR(100) NOT NULL,
goal_cents INT UNSIGNED NOT NULL,
currency CHAR(3) NOT NULL,
status ENUM('open','closed') DEFAULT 'open',
deadline DATE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_tenant (tenant_id)
);
CREATE TABLE donations (
id CHAR(36) PRIMARY KEY,
campaign_id CHAR(36) NOT NULL,
donor_name VARCHAR(80) NOT NULL,
amount_cents INT UNSIGNED NOT NULL,
recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (campaign_id) REFERENCES campaigns(id)
);
Tenant Resolution
Tenant identity is resolved at the HTTP boundary — in Infrastructure, before the controller runs. It never leaks into the Domain as a raw string from an unvalidated source.
final class TenantResolver
{
public function __construct(private \PDO $pdo) {}
public function resolve(\Psr\Http\Message\ServerRequestInterface $request): string
{
// Option A: subdomain
$host = $request->getHeaderLine('Host');
$parts = explode('.', $host);
$slug = count($parts) >= 3 ? $parts[0] : null;
// Option B: explicit header (useful for local dev)
$header = $request->getHeaderLine('X-Tenant-ID');
$tenantId = $header ?: $this->lookupSlug($slug);
if ($tenantId === null) {
throw new TenantNotFoundException();
}
return $tenantId;
}
private function lookupSlug(?string $slug): ?string
{
if ($slug === null) return null;
$stmt = $this->pdo->prepare(
'SELECT id FROM tenants WHERE slug = :slug LIMIT 1'
);
$stmt->execute(['slug' => $slug]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
return $row['id'] ?? null;
}
}
Tenants table
CREATE TABLE tenants (
id CHAR(36) PRIMARY KEY,
slug VARCHAR(50) UNIQUE NOT NULL,
name VARCHAR(100) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Seed two tenants for local dev
INSERT INTO tenants VALUES
('11111111-1111-4111-a111-111111111111', 'grace-church', 'Grace Church'),
('22222222-2222-4222-a222-222222222222', 'hope-chapel', 'Hope Chapel');
How the Controller uses it
final class CampaignController
{
public function __construct(
private HandlerBus $bus,
private TenantResolver $resolver,
private CampaignFinder $finder
) {}
public function index(Request $request): JsonResponse
{
$tenantId = $this->resolver->resolve($request);
return new JsonResponse(
$this->finder->allForTenant($tenantId)
);
}
public function store(Request $request): JsonResponse
{
$tenantId = $this->resolver->resolve($request);
$body = $request->getParsedBody();
$this->bus->dispatch(new CreateCampaignCommand(
tenantId: $tenantId,
campaignId: Uuid::v4(),
name: $body['name'],
goalCents: (int) $body['goal_cents'],
currency: $body['currency'],
deadline: $body['deadline'],
));
return new JsonResponse(null, 201);
}
}
Vue 3 + TypeScript Pages
Four pages, all following Tithely coding standards. Strict TypeScript, Lodash for every data operation, backtick-only strings, and all text through $t().
Pages and key requirements
/ — Campaign List
Fetch all campaigns for the resolved tenant. Use _.orderBy for sorting and a search input wired to _.filter. Show status badge with CVA bound directly in the template.
/campaigns/[id] — Detail
Progress bar toward goal. Donor list built with _.chain(donations).groupBy('donorName').mapValues(ds => _.sumBy(ds, 'amountCents')).toPairs().orderBy([1], ['desc']).value().
/campaigns/[id]/donate
Multi-step form: amount → donor name → confirm. State and submit logic in a useDonation composable. No inline logic in the template.
/campaigns/new
Create campaign form. Validates name length and goal > 0 client-side before posting. Uses useI18n for all validation messages.
Standards checklist for every component
- Block order:
script setup→template→style scoped - Exported
interface Props— no inline type, nowithDefaults - Backtick strings everywhere — no single or double quotes
- Arrow functions for all methods and composables
- Lodash for all array operations — no native
.filter().map() const { t: $t } = useI18n()— every user-facing string through i18n- CVA bound directly:
:class="statusClasses({ status })"— never incomputed() - No
anytype — useunknownwith type guards where needed
Docker — Required Setup
The entire stack must start with docker-compose up. Multi-stage Dockerfiles are required — the production image must contain no dev dependencies and no source code mounted as a volume.
Services in docker-compose.yml
/api/* to php-fpm and /* to the Vue appEntrypoint requirement: the PHP container entrypoint must run db/migrations/*.sql files in order before starting php-fpm, so docker-compose up produces a fully migrated database.
FROM composer:2 AS deps
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader
FROM php:8.3-fpm AS runtime
WORKDIR /app
COPY --from=deps /app/vendor ./vendor
COPY . .
RUN chown -R www-data:www-data /app
USER www-data
CMD ["php-fpm"]
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine AS runtime
COPY --from=build /app/.output/public /usr/share/nginx/html
COPY docker/nginx/vue.conf /etc/nginx/conf.d/default.conf
Secrets rule: all credentials via environment variables only. No hardcoded values in any committed file. Provide a complete .env.example.
Deploy to Free Tier — Pick One
Choose either AWS or Google Cloud. The goal is a working public URL that reviewers can open. Document every step in docs/deployment.md so the setup is reproducible and teardown is safe.
Option A — AWS Free Tier
- Use the AWS Free Tier EC2 t2.micro (750 hours/month)
- MySQL runs inside Docker — no RDS needed to stay in free tier
- Use the EC2 public IP or a free domain (e.g. nip.io) for the live URL
- Configure tenant slugs to resolve by
X-Tenant-IDheader if no custom domain is available
Option B — Google Cloud Free Tier
- GCE e2-micro is always free in qualifying regions — no credit card charge
- Use the VM's external IP for the live URL
- Advanced option: split PHP and Vue into two Cloud Run services + use Cloud SQL (90-day trial credits)
Required deliverable: a docs/deployment.md with every CLI command run, all environment variables configured, and a teardown checklist to avoid billing surprises after the review.
Acceptance Criteria & Rubric
Reviewers will check each item below. Architecture depth is weighted more than feature breadth — a smaller feature set with correct layering scores higher than all features with leaky layers.
Domain & Architecture
TenantIdrejects invalid UUIDs with a domain exception- Donation on a closed campaign throws inside the aggregate — not the controller
- Closing a campaign before goal is reached throws inside the aggregate
- Cross-tenant mutation throws
TenantMismatchException - Zero framework imports inside
Domain/— grep verifiable - All Commands carry only scalar fields (string, int, bool)
CampaignRepositoryInterfacedefined inDomain/, implemented inInfrastructure/CampaignFinderreturns plain arrays — never entities or Value Objects- Every SQL query in repository and finder is parameterized — no string interpolation
TenantResolverfails with a 404 response for unknown tenant slugs
Frontend
tsc --strict --noEmitpasses with zero errors- No
anytype anywhere infrontend/ - Every user-facing string goes through
$t()— no hardcoded English in templates - Donor list on detail page uses a Lodash
_.chain()pipeline - No
withDefaults(), no mixed quotes, no function declarations - CVA bound directly in template — never inside
computed()
Infrastructure & Deployment
docker-compose upboots the full app including migrations- Multi-stage Dockerfiles — prod image has no dev deps, no source volumes
- A working public URL is submitted with the project
docs/deployment.mdis complete and the URL is reproducible- No secrets committed to the repository
Evaluation weights
| Area | Weight |
|---|---|
| Domain modeling & invariants | 30% |
| Architecture discipline (layer separation) | 25% |
| Frontend standards compliance | 20% |
| Containerization quality | 15% |
| Deployment & live URL | 10% |