<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Firebase\JWT\JWT;
use Stripe\StripeClient;
use Ramsey\Uuid\Uuid;
use Dotenv\Dotenv;
/**
* Multi-Tenant SaaS Starter Kit
*
* Features:
* - JWT Authentication
* - Tenant Isolation with UUIDs
* - Stripe Subscription Integration
* - Role-Based Access Control
* - RESTful API Design
* - Database Migrations
* - Admin Dashboard
*/
// Load environment variables
$dotenv = Dotenv::createImmutable(__DIR__);
$dotenv->load();
class Database {
private static $instance = null;
private $connection;
private function __construct() {
$dsn = sprintf(
'mysql:host=%s;dbname=%s;charset=%s',
$_ENV['DB_HOST'],
$_ENV['DB_NAME'],
'utf8mb4'
);
$this->connection = new PDO($dsn, $_ENV['DB_USER'], $_ENV['DB_PASS'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
}
public static function getInstance(): self {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public function getConnection(): PDO {
return $this->connection;
}
public function migrate(): void {
$migrations = [
"CREATE TABLE IF NOT EXISTS tenants (
id BINARY(16) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
domain VARCHAR(255) UNIQUE NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)",
"CREATE TABLE IF NOT EXISTS users (
id BINARY(16) PRIMARY KEY,
tenant_id BINARY(16) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
role ENUM('admin', 'manager', 'user') DEFAULT 'user',
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)",
"CREATE TABLE IF NOT EXISTS subscriptions (
id BINARY(16) PRIMARY KEY,
tenant_id BINARY(16) NOT NULL,
stripe_subscription_id VARCHAR(255),
plan_id VARCHAR(50) NOT NULL,
status VARCHAR(20) NOT NULL,
current_period_end TIMESTAMP,
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)"
];
$db = $this->getConnection();
foreach ($migrations as $migration) {
$db->exec($migration);
}
}
}
class Auth {
private $db;
public function __construct() {
$this->db = Database::getInstance()->getConnection();
}
public function registerTenant(string $name, string $domain, string $email, string $password): array {
// Validate input
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException("Invalid email address");
}
// Check if domain exists
$stmt = $this->db->prepare("SELECT id FROM tenants WHERE domain = ?");
$stmt->execute([$domain]);
if ($stmt->fetch()) {
throw new RuntimeException("Domain already registered");
}
// Start transaction
$this->db->beginTransaction();
try {
// Create tenant
$tenantId = Uuid::uuid4()->getBytes();
$stmt = $this->db->prepare("INSERT INTO tenants (id, name, domain) VALUES (?, ?, ?)");
$stmt->execute([$tenantId, $name, $domain]);
// Create admin user
$userId = Uuid::uuid4()->getBytes();
$hashedPassword = password_hash($password, PASSWORD_BCRYPT);
$stmt = $this->db->prepare("INSERT INTO users (id, tenant_id, email, password, role) VALUES (?, ?, ?, ?, 'admin')");
$stmt->execute([$userId, $tenantId, $email, $hashedPassword]);
$this->db->commit();
return [
'tenant_id' => Uuid::fromBytes($tenantId)->toString(),
'user_id' => Uuid::fromBytes($userId)->toString()
];
} catch (Exception $e) {
$this->db->rollBack();
throw $e;
}
}
public function login(string $email, string $password): string {
$stmt = $this->db->prepare("SELECT u.id, u.tenant_id, u.password, u.role, t.domain
FROM users u JOIN tenants t ON u.tenant_id = t.id
WHERE u.email = ?");
$stmt->execute([$email]);
$user = $stmt->fetch();
if (!$user || !password_verify($password, $user['password'])) {
throw new RuntimeException("Invalid credentials");
}
$payload = [
'sub' => Uuid::fromBytes($user['id'])->toString(),
'tid' => Uuid::fromBytes($user['tenant_id'])->toString(),
'dom' => $user['domain'],
'role' => $user['role'],
'iat' => time(),
'exp' => time() + 3600 // 1 hour expiration
];
return JWT::encode($payload, $_ENV['JWT_SECRET'], 'HS256');
}
public function validateToken(string $token): array {
try {
$decoded = JWT::decode($token, $_ENV['JWT_SECRET'], ['HS256']);
return (array)$decoded;
} catch (Exception $e) {
throw new RuntimeException("Invalid token: " . $e->getMessage());
}
}
}
class SubscriptionService {
private $db;
private $stripe;
public function __construct() {
$this->db = Database::getInstance()->getConnection();
$this->stripe = new StripeClient($_ENV['STRIPE_SECRET_KEY']);
}
public function createSubscription(string $tenantId, string $planId, string $paymentMethodId): array {
$tenant = $this->getTenant($tenantId);
// Create Stripe customer if not exists
$customer = $this->stripe->customers->create([
'email' => $this->getTenantAdminEmail($tenantId),
'payment_method' => $paymentMethodId,
'invoice_settings' => ['default_payment_method' => $paymentMethodId]
]);
// Create subscription
$subscription = $this->stripe->subscriptions->create([
'customer' => $customer->id,
'items' => [['price' => $planId]],
'expand' => ['latest_invoice.payment_intent']
]);
// Save to database
$subscriptionId = Uuid::uuid4()->getBytes();
$stmt = $this->db->prepare("INSERT INTO subscriptions
(id, tenant_id, stripe_subscription_id, plan_id, status, current_period_end)
VALUES (?, ?, ?, ?, ?, ?)");
$stmt->execute([
$subscriptionId,
Uuid::fromString($tenantId)->getBytes(),
$subscription->id,
$planId,
$subscription->status,
date('Y-m-d H:i:s', $subscription->current_period_end)
]);
return [
'subscription_id' => Uuid::fromBytes($subscriptionId)->toString(),
'status' => $subscription->status,
'client_secret' => $subscription->latest_invoice->payment_intent->client_secret
];
}
private function getTenant(string $tenantId): array {
$stmt = $this->db->prepare("SELECT * FROM tenants WHERE id = ?");
$stmt->execute([Uuid::fromString($tenantId)->getBytes()]);
$tenant = $stmt->fetch();
if (!$tenant) {
throw new RuntimeException("Tenant not found");
}
return $tenant;
}
private function getTenantAdminEmail(string $tenantId): string {
$stmt = $this->db->prepare("SELECT email FROM users WHERE tenant_id = ? AND role = 'admin' LIMIT 1");
$stmt->execute([Uuid::fromString($tenantId)->getBytes()]);
$user = $stmt->fetch();
if (!$user) {
throw new RuntimeException("Admin user not found for tenant");
}
return $user['email'];
}
}
class TenantMiddleware {
public function __invoke($request, $handler) {
$authHeader = $request->getHeaderLine('Authorization');
$token = str_replace('Bearer ', '', $authHeader);
try {
$auth = new Auth();
$payload = $auth->validateToken($token);
// Add tenant context to request
$request = $request->withAttribute('tenant_id', $payload['tid'])
->withAttribute('user_id', $payload['sub'])
->withAttribute('user_role', $payload['role']);
return $handler->handle($request);
} catch (Exception $e) {
return new JsonResponse(['error' => $e->getMessage()], 401);
}
}
}
// Example API Endpoints
$app = new \Slim\App();
// Public routes
$app->post('/api/register', function ($request, $response) {
$data = $request->getParsedBody();
$auth = new Auth();
try {
$result = $auth->registerTenant(
$data['name'],
$data['domain'],
$data['email'],
$data['password']
);
return $response->withJson($result);
} catch (Exception $e) {
return $response->withJson(['error' => $e->getMessage()], 400);
}
});
$app->post('/api/login', function ($request, $response) {
$data = $request->getParsedBody();
$auth = new Auth();
try {
$token = $auth->login($data['email'], $data['password']);
return $response->withJson(['token' => $token]);
} catch (Exception $e) {
return $response->withJson(['error' => $e->getMessage()], 401);
}
});
// Protected routes (require JWT)
$app->group('/api', function () {
$this->post('/subscriptions', function ($request, $response) {
$data = $request->getParsedBody();
$tenantId = $request->getAttribute('tenant_id');
try {
$service = new SubscriptionService();
$result = $service->createSubscription(
$tenantId,
$data['plan_id'],
$data['payment_method_id']
);
return $response->withJson($result);
} catch (Exception $e) {
return $response->withJson(['error' => $e->getMessage()], 400);
}
});
$this->get('/me', function ($request, $response) {
return $response->withJson([
'tenant_id' => $request->getAttribute('tenant_id'),
'user_id' => $request->getAttribute('user_id'),
'role' => $request->getAttribute('user_role')
]);
});
})->add(new TenantMiddleware());
// Initialize database
Database::getInstance()->migrate();
$app->run();