Santos Rivera's PHP starter kit. A consistent foundation for new SaaS projects: custom MVC, OTP + Magic Link auth (no passwords, ever), a mailer, and Composer + npm/Vite wired together.
- PHP 8.2, custom MVC (no framework dependency)
- MySQL via PDO
- Tailwind CSS + Vite (npm)
- Vanilla JS
- PHPMailer (SMTP + log driver)
- Stripe Checkout + Billing Portal for subscription billing
- Local/private file storage abstraction
- Anthropic API wrapper for text, JSON, and image-assisted prompts
- DB-backed queue worker for async jobs
- Optional one-organization-per-user multi-tenancy layer
- OTP and/or Magic Link auth — toggle in
.env - CSRF protection, request throttling, and branded error pages
public_html/ Web root. Only this folder is exposed by the server.
index.php Front controller — every request enters here.
assets/ Built CSS/JS output (npm run build). Git-ignored.
uploads/ User-uploaded files.
src/
Core/ Framework internals: Router, Request, Response, Database,
Session, View, Mailer, Vite, Env, Controller, Middleware,
Csrf, RateLimiter, ErrorHandler, Storage.
App/
Controllers/ Your route handlers.
Middleware/ Route guards (AuthMiddleware included).
Models/ Thin data-access classes.
Services/ Business logic (OtpService, MagicLinkService, AiService).
routes/
web.php All routes are registered here.
views/ Plain PHP templates. No templating engine —
views/partials/head.php + one file per page, same as
the pattern you've used across keel/Mise/ShiftDeduct.
resources/
css/app.css Tailwind entry point.
js/app.js JS entry point.
database/
migrations/ Plain .sql files, run with `php database/migrate.php`.
migrate.php CLI runner for pending SQL migrations.
queue-work.php CLI worker for queued jobs.
storage/logs/ App logs (error_log target if you wire one in).
storage/app/ Private uploaded files, not web-accessible.
-
Install dependencies
composer install npm install -
Environment
cp .env.example .envFill in
DB_*,MAIL_*, and setAPP_URLto wherever this is served locally (e.g.http://keel.localfollowing your existing local-dev pattern, orhttp://localhost:8000).Fastest local auth smoke test without SMTP setup:
MAIL_MAILER=logThen request an OTP or magic link and read
storage/logs/mail.logfor the code or URL.Quick troubleshooting for
MAIL_MAILER=log:[2026-07-10 02:58:48] MAIL_MAILER=log To: you@example.com <you@example.com> Subject: Your verification code Text Body: Keel App verification code Use this code to sign in. It expires in 10 minutes. 315638For OTP, use the 6-digit code in
Text Body. For magic-link auth, open the/auth/magic?token=...&email=...URL logged in the same entry. -
Database
php database/migrate.phpThis creates the configured database automatically if it does not exist yet, runs any pending SQL files in
database/migrations/, records them in amigrationstable, and createsusers,auth_tokens, and any later starter-kit tables such assubscriptions. YourDB_USERNAMEmust have permission to create databases on the target MySQL server.Security-related tables such as
rate_limitsare created by later migrations the same way. -
File storage + AI config Add these to
.envfor upload handling and Anthropic-powered features:FILESYSTEM_DISK=local FILESYSTEM_MAX_UPLOAD_MB=10 FILESYSTEM_ALLOWED_EXTENSIONS=pdf,jpg,jpeg,png,heic ANTHROPIC_API_KEY= ANTHROPIC_MODEL=claude-sonnet-4-5Public uploads are stored under
public_html/uploads/. Private uploads are stored understorage/app/and are only served through authenticated controller checks. -
Optional multi-tenancy
MULTI_TENANCY_ENABLED=false
Leave this
falseand Keel behaves exactly as it does today. Set it totruefor one organization per user, invite-based teammate onboarding, org admin settings, and a platform-level super-admin area. -
Stripe billing (optional, but included in the kit) Add these to
.envwhen you want to test or ship subscription billing:STRIPE_SECRET_KEY= STRIPE_PUBLISHABLE_KEY= STRIPE_WEBHOOK_SECRET= STRIPE_PRICE_PRO_MONTHLY=For local webhook testing, use the Stripe CLI:
stripe listen --forward-to keel.local/webhooks/stripeStripe prints a temporary signing secret. Put that value into
STRIPE_WEBHOOK_SECRETlocally instead of using a live dashboard secret. -
Choose your auth method In
.env:AUTH_METHOD=otp # OTP only AUTH_METHOD=magic_link # Magic link only AUTH_METHOD=both # Both, with a tab switcher on the login page -
Local vhost (XAMPP), same pattern as keel.local
a. Copy this project into
C:\xampp\htdocs\keel(so the front controller lives atC:\xampp\htdocs\keel\public_html\index.php).b. Add to
C:\Windows\System32\drivers\etc\hosts:127.0.0.1 keel.localc. Add to
C:\xampp\apache\conf\extra\httpd-vhosts.conf:<VirtualHost *:80> ServerName keel.local DocumentRoot "C:/xampp/htdocs/keel/public_html" <Directory "C:/xampp/htdocs/keel/public_html"> Options Indexes FollowSymLinks AllowOverride All Require all granted </Directory> </VirtualHost>AllowOverride Allis required — without it,public_html/.htaccessis ignored and every route except/404s.d. Confirm
httpd-vhosts.confis loaded — inC:\xampp\apache\conf\httpd.confthere should be an uncommentedInclude conf/extra/httpd-vhosts.conf. If you already havekeel.localworking, this is already done.e. Restart Apache from the XAMPP control panel.
f. Set
APP_URL=http://keel.localin.env.public_html/.htaccessis already in the project — it rewrites any request that isn't a real file toindex.php, which is what lets/login,/dashboard, etc. resolve through the router instead of 404ing. -
Run the asset pipeline
npm run devThis starts the Vite dev server and writes
public_html/hot, which the PHPVitehelper detects automatically to serve unbuilt assets with HMR — no code change needed to switch between dev and build. Stop the dev server and it cleans that file up on its own. Apache serves the PHP as usual athttp://keel.local; Vite only serves the JS/CSS.For production:
npm run build, then just visithttp://keel.local— theVitehelper readspublic_html/assets/.vite/manifest.jsonautomatically. No dev server needed.
XAMPP + keel.local remains the primary documented workflow. Docker is provided as an optional path for contributors.
-
Start services:
docker compose up --build
-
Install dependencies inside the app container if needed:
docker compose exec app composer install docker compose exec app npm install
-
Run migrations against the
dbservice:docker compose exec app php database/migrate.php -
Visit
http://localhost:8080.
In Docker, app database host is wired to db in docker-compose.yml.
- OTP: 6-digit code, hashed with
password_hash(), expires in 10 minutes, rate-limited to 5 requests per 15 minutes per user. - Magic Link: 32-byte random token, hashed with SHA-256, expires in 15 minutes, single-use, same rate limit.
- Both write to
auth_tokens. A successful verify creates a session (Session::put('user_id', ...)), regenerates the session ID, and redirects to/dashboard. AuthMiddlewareguards any route group that needs a logged-in user — seeroutes/web.phpfor the pattern.
- Billing uses Stripe-hosted Checkout and the Stripe Billing Portal only. Keel never handles raw card data directly.
BillingServicestarts Checkout sessions, opens Billing Portal sessions, and syncs local subscription state from Stripe webhooks.POST /webhooks/stripeverifies theStripe-Signatureheader withSTRIPE_WEBHOOK_SECRETbefore updating the localsubscriptionstable.SubscriptionMiddlewareis available for projects built on Keel that need to gate features behind an active or trialing subscription.
Storagevalidates uploaded files by actual MIME type usingfinfo, not by trusting client-supplied content types.- Executable-adjacent extensions are rejected even if they appear in the configured allowed-extension list.
- Private files are never served directly from disk; they flow through
GET /files/{id}and an ownership check first. AiServicewraps Anthropic's Messages API with plaincurl, supports text completions, JSON-only responses, and image + prompt requests.
- Multi-tenancy is opt-in through
MULTI_TENANCY_ENABLED. - When enabled, users belong to at most one organization via
users.organization_id, with roles stored directly onusers.role. - New users without an organization are routed to
/onboarding/organizationafter login. - Organization owners and admins can invite teammates by email. Invite tokens are hashed at rest, single-use, and expiring.
- Invite emails are queued for background delivery by
database/queue-work.phpso invite requests do not block HTTP responses. is_super_adminis a manual database flag for the platform operator and is never self-assignable through the UI.
- Keel includes a simple database-backed queue (
jobsandfailed_jobs) with no Redis or external broker. - Push work with
Keel\Core\Queue::push(...); process jobs with the worker script below. - OTP and magic-link delivery intentionally stay synchronous so sign-in remains immediate and predictable.
Run one pass (for cron):
php database/queue-work.php --onceRun continuously (for supervised workers):
php database/queue-work.php- Cron (simple, low volume): run
php database/queue-work.php --onceevery minute. - Supervised long-running process (higher volume/lower latency): run
php database/queue-work.phpunder systemd or Supervisor.
Keel does not install process supervision for you; choose the option that fits your hosting environment.
Use this helper to generate repeatable sample data for the activity pages:
php database/seed-activity.phpOptions:
--count=50number of rows to generate (default 30, max 500)--email=activity-seed@example.comuser email to seed under--org-id=1include an organization id on seeded rows--appendkeep prior seeded rows instead of replacing them
The script is intentionally blocked outside local/dev/testing environments.
- State-changing requests are protected by CSRF tokens. The shared head partial outputs a
csrf-tokenmeta tag, and forms can use\Keel\Core\Csrf::field(). - Auth-related endpoints sit behind an IP-based throttle keyed by client IP and route path.
- Webhook routes stay outside CSRF and throttle middleware because they are verified by third-party signatures instead.
- Missing routes render a branded 404 page, and uncaught exceptions render a branded 500 page.
public_html/index.phpsets baseline headers on every response:X-Content-Type-Options: nosniff,X-Frame-Options: DENY, andReferrer-Policy: strict-origin-when-cross-origin.
GET /upis an unauthenticated health endpoint for load balancers and uptime monitors.- It returns
200with{"status":"ok","database":true}when DB connectivity succeeds. - It returns
503with{"status":"ok","database":false}when the database cannot be reached.
- Unit and feature tests live in
tests/and run with PHPUnit. - Run locally with
./vendor/bin/phpunit(orvendor\\bin\\phpuniton Windows). - GitHub Actions (
.github/workflows/ci.yml) runs on every push and pull request:- PHP 8.2 setup
- MySQL service
composer install- SQL migrations
npm install && npm run build- PHPUnit
See CONTRIBUTING.md for contribution and PR expectations.
- Copy the whole folder, rename it.
- Update
composer.json(name),package.json(name),.env(APP_NAME,APP_URL,DB_DATABASE). - Add controllers to
src/App/Controllers/, register routes inroutes/web.php, add views underviews/. - Keep business logic in
src/App/Services/, keep controllers thin — same separation you've used on keel and PulseIQ.
- No query builder — raw PDO with prepared statements throughout. Add one if a project needs it.
- No CLI/scaffolding generator (no
php keel make:controlleryet). Can add if it'd save time across projects. - Sessions are native PHP sessions, not DB-backed. Fine for single-server; revisit if you ever load-balance across multiple app servers.
- Mail templates are inline HTML strings in the services for now — pull them into
views/emails/if they grow.