A robust, API-first Multi-Tenant POS/Inventory Management Backend System built with Laravel 12. This system provides secure tenant data isolation, efficient performance, and modern development practices.
- Features
- System Requirements
- Installation
- Architecture Overview
- Multi-Tenancy Strategy
- Authentication & Authorization
- API Documentation
- Database Schema
- Performance Considerations
- Testing
- Docker Setup
- Multi-Tenancy: Complete tenant data isolation via
X-Tenant-IDheader - Authentication: Laravel Sanctum token-based API authentication
- Role-Based Access Control: Owner and Staff roles with policy-based authorization
- Inventory Management: Products with SKU (unique per tenant), stock tracking, low stock alerts
- Order Processing: Transaction-based order creation with stock deduction and cancellation with stock restoration
- Reporting: Daily sales summary, top selling products, low stock reports
- API Standards: RESTful API with Laravel Resources, pagination, and rate limiting
- PHP >= 8.2
- MySQL >= 8.0
- Composer >= 2.0
- Node.js >= 18.0 (for asset compilation)
- Redis (optional, for production rate limiting)
-
Clone the repository
git clone https://github.com/your-username/mini-saas-pos-backend.git cd mini-saas-pos-backend -
Install PHP dependencies
composer install
-
Configure environment
cp .env.example .env php artisan key:generate
-
Configure database in
.envDB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 DB_DATABASE=mini_saas_pos DB_USERNAME=your_username DB_PASSWORD=your_password
-
Run migrations
php artisan migrate
-
Start the development server
php artisan serve
-
Build and start containers
docker-compose up -d --build
-
Install dependencies inside container
docker-compose exec app composer install docker-compose exec app php artisan key:generate docker-compose exec app php artisan migrate
-
Access the application at
http://localhost:8000
app/
├── Enums/ # PHP Enums for type-safe status values
│ ├── OrderStatus.php
│ └── UserRole.php
├── Exceptions/ # Custom exception classes
│ └── InsufficientStockException.php
├── Http/
│ ├── Controllers/Api/ # API controllers
│ ├── Middleware/ # Custom middleware (ResolveTenantByHeader)
│ ├── Requests/Api/ # Form Request validation classes
│ └── Resources/Api/ # API Resource transformers
├── Models/ # Eloquent models with BelongsToTenant trait
├── Policies/ # Authorization policies
├── Services/ # Business logic services
│ ├── CurrentTenant.php
│ ├── OrderService.php
│ └── ReportService.php
└── Traits/ # Reusable traits
└── BelongsToTenant.php
The system implements a single-database, shared-schema multi-tenancy approach where all tenants share the same database tables, but data is isolated using a tenant_id foreign key.
- HTTP Request arrives with
X-Tenant-IDheader - ResolveTenantByHeader Middleware validates and resolves the tenant
- CurrentTenant Service (singleton) stores the tenant context
- TenantScope (global scope) automatically filters all queries by
tenant_id - BelongsToTenant Trait automatically sets
tenant_idon model creation
// Middleware resolves tenant from header
$tenantId = $request->header('X-Tenant-ID');
$tenant = Tenant::find($tenantId);
app(CurrentTenant::class)->set($tenant);
// TenantScope automatically filters queries
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
if (app(CurrentTenant::class)->check()) {
$builder->where('tenant_id', app(CurrentTenant::class)->id());
}
}
}- Tenant context is ONLY resolved from the
X-Tenant-IDheader (never from request body) - Global scopes ensure queries are always filtered by tenant
- Policies perform double-check by verifying user's tenant matches resource's tenant
- Inactive tenants are denied access at the middleware level
- Register:
POST /api/v1/auth/register - Login:
POST /api/v1/auth/login - Get Profile:
GET /api/v1/auth/me - Logout:
POST /api/v1/auth/logout
| Role | Products | Customers | Orders | Reports |
|---|---|---|---|---|
| Owner | Full CRUD | Full CRUD | Full CRUD + Cancel | View |
| Staff | View only | View only | View + Create | View |
Authorization is NOT hard-coded in controllers. Policies are used:
// In controller
$this->authorize('create', Product::class);
$this->authorize('update', $product);
// Policy checks both permission AND tenant ownership
public function update(User $user, Product $product): bool
{
return $user->isOwner() && $user->tenant_id === $product->tenant_id;
}http://localhost:8000/api/v1
| Header | Required | Description |
|---|---|---|
Authorization |
Yes* | Bearer {token} for protected routes |
X-Tenant-ID |
Yes** | Tenant ID for tenant-scoped routes |
Accept |
Recommended | application/json |
*Required for all routes except register/login **Required for routes under tenant middleware
POST /auth/register- Register new userPOST /auth/login- Login and get tokenGET /auth/me- Get authenticated userPOST /auth/logout- Revoke current token
GET /products- List products (paginated, searchable)POST /products- Create product (Owner only)GET /products/{id}- Get productPUT /products/{id}- Update product (Owner only)DELETE /products/{id}- Delete product (Owner only)
GET /customers- List customersPOST /customers- Create customer (Owner only)GET /customers/{id}- Get customerPUT /customers/{id}- Update customer (Owner only)DELETE /customers/{id}- Delete customer (Owner only)
GET /orders- List orders (filterable by status, date)POST /orders- Create order (Owner + Staff)GET /orders/{id}- Get order with itemsPATCH /orders/{id}/status- Update order status (Owner only)POST /orders/{id}/cancel- Cancel order and restore stock (Owner only)
GET /reports/daily-sales?date=YYYY-MM-DD- Daily sales summaryGET /reports/top-selling-products?start_date=...&end_date=...- Top 5 productsGET /reports/low-stock- Low stock products
{
"success": true,
"message": "Operation successful",
"data": { ... }
}Error responses:
{
"success": false,
"message": "Error description",
"errors": { ... }
}tenants- Business/tenant informationusers- Users withtenant_idandroleproducts- Products with unique SKU per tenantcustomers- Customer informationorders- Order headers with status trackingorder_items- Order line items
-- Products
UNIQUE INDEX (tenant_id, sku)
INDEX (tenant_id, stock_quantity)
-- Orders
UNIQUE INDEX (tenant_id, order_number)
INDEX (tenant_id, status, created_at)
-- Customers
INDEX (tenant_id, email)
INDEX (tenant_id, phone)All API endpoints use eager loading to prevent N+1 queries:
Order::with(['customer', 'items.product'])->paginate(15);Indexes are added for:
- Tenant scoping queries (
tenant_id) - Unique constraints (
tenant_id + sku,tenant_id + order_number) - Reporting queries (
tenant_id + status + created_at) - Stock queries (
tenant_id + stock_quantity)
Daily sales reports are cached for past dates (TTL: 30 minutes) since historical data doesn't change:
Cache::remember("daily_sales_{$tenantId}_{$date}", 1800, fn () => ...);All order operations use database transactions with row-level locking:
DB::transaction(function () {
$products = Product::whereIn('id', $ids)->lockForUpdate()->get();
// ... create order, deduct stock
});# All tests
php artisan test
# Specific test file
php artisan test tests/Feature/TenantIsolationTest.php
# Filter by name
php artisan test --filter=tenant- TenantIsolationTest: Verifies complete tenant data isolation
- AuthenticationTest: Tests registration, login, and token management
- ProductControllerTest: Tests product CRUD with authorization
- OrderControllerTest: Tests order workflow including stock transactions
- app: PHP 8.2 FPM with Laravel
- webserver: Nginx
- db: MySQL 8.0
- redis: Redis (for caching/queues)
# Start services
docker-compose up -d
# Run migrations
docker-compose exec app php artisan migrate
# Run tests
docker-compose exec app php artisan test
# Stop services
docker-compose downThis project is open-sourced software licensed under the MIT license.