quellabs / canvas
A modern, lightweight PHP framework with contextual containers, automatic service discovery, and ObjectQuel ORM integration
Requires
- ext-curl: *
- ext-fileinfo: *
- ext-gd: *
- ext-json: *
- ext-mysqli: *
- ext-pdo: *
- quellabs/annotation-reader: ^1.0
- quellabs/canvas-smarty: ^1.0
- quellabs/contracts: ^1.0
- quellabs/dependency-injection: ^1.0
- quellabs/discover: ^1.0
- quellabs/sculpt: ^1.0
- quellabs/signal-hub: ^1.0
- symfony/http-foundation: *
README
A modern, lightweight PHP framework that gets out of your way. Write clean controllers with route annotations, query your database with an intuitive ORM, and let contextual containers handle the complexity. Built to work seamlessly alongside your existing PHP codebase - modernize incrementally without breaking what already works.
What Makes Canvas Different
- 🔄 Legacy-First Integration - Drop into any existing PHP app without breaking existing URLs
- 🎯 Annotation-Based Routing - Define routes directly in controllers with
@Route
annotations - 🗄️ ObjectQuel ORM - Query databases using intuitive, natural PHP syntax
- 📦 Contextual Containers - Work with interfaces; Canvas resolves implementations by context
- ⚡ Aspect-Oriented Programming - Add crosscutting concerns without cluttering business logic
Quick Start
Installation
# New project composer create-project quellabs/canvas-skeleton my-app # Existing project composer require quellabs/canvas
Bootstrap (public/index.php)
This bootstrap file is automatically generated when using the skeleton package. If you're not using the skeleton package, you'll need to create this file manually.
<?php use Quellabs\Canvas\Kernel; use Symfony\Component\HttpFoundation\Request; require_once __DIR__ . '/../vendor/autoload.php'; $kernel = new Kernel(); $request = Request::createFromGlobals(); $response = $kernel->handle($request); $response->send();
Controllers with Route Annotations
Canvas automatically discovers controllers and registers their routes using annotations:
<?php namespace App\Controllers; use Quellabs\Canvas\Annotations\Route; use Quellabs\Canvas\Controllers\BaseController; class BlogController extends BaseController { /** * @Route("/") */ public function index() { return $this->render('home.tpl'); } /** * @Route("/posts") */ public function list() { $posts = $this->em->findBy(Post::class, ['published' => true]); return $this->render('posts.tpl', $posts); } /** * @Route("/posts/{id:int}") */ public function show(int $id) { $post = $this->em->find(Post::class, $id); return $this->render('post.tpl', $post); } }
ObjectQuel ORM
ObjectQuel lets you query data using syntax that feels natural to PHP developers. Inspired by QUEL (a declarative query language from early relational databases), it bridges traditional database querying with object-oriented programming.
Simple Entity Operations
// Find by primary key - fastest lookup method $user = $this->em->find(User::class, $id); // Simple filtering using findBy - perfect for basic criteria $activeUsers = $this->em->findBy(User::class, ['active' => true]); $recentPosts = $this->em->findBy(Post::class, ['published' => true]);
Advanced ObjectQuel Queries
For complex queries, ObjectQuel provides a natural language syntax:
// Basic ObjectQuel query $results = $this->em->executeQuery(" range of p is App\\Entity\\Post retrieve p where p.published = true sort by p.publishedAt desc "); // Queries with relationships and parameters $techPosts = $this->em->executeQuery(" range of p is App\\Entity\\Post range of u is App\\Entity\\User via p.authorId retrieve (p, u.name) where p.title = /^Tech/i and p.published = :published sort by p.publishedAt desc ", [ 'published' => true ]);
Key Components
range
- Creates an alias for an entity class, similar to SQL'sFROM
clause. Think of it as "let p represent App\Entity\Post"retrieve
- Functions like SQL'sSELECT
, specifying what data to return. You can retrieve entire entities (p
) or specific properties (u.name
)where
- Standard filtering conditions, supporting parameters (:published
) and regular expressions (/^Tech/i
matches titles starting with "Tech", case-insensitive)sort by
- Equivalent to SQL'sORDER BY
for result orderingvia
- Establishes relationships between entities using foreign keys (p.authorId
links posts to users)
ObjectQuel Features
- Readability: More intuitive than complex Doctrine DQL or QueryBuilder syntax
- Type Safety: Entity relationships are validated at query time
- Parameter Binding: Safe parameter substitution prevents SQL injection
- Relationship Traversal: Easily query across entity relationships with
via
keyword - Flexible Sorting: Multi-column sorting with
sort by field1 asc, field2 desc
Legacy Integration
Canvas is designed to work seamlessly alongside existing PHP codebases, allowing you to modernize your applications incrementally without breaking existing functionality. The legacy integration system provides a smooth migration path from traditional PHP applications to Canvas.
Quick Start with Legacy Code
Start using Canvas today in your existing PHP application. No rewrites required - Canvas's intelligent fallthrough system lets you modernize at your own pace.
First, enable legacy support by updating your public/index.php
:
<?php // public/index.php use Quellabs\Canvas\Kernel; use Symfony\Component\HttpFoundation\Request; require_once __DIR__ . '/../vendor/autoload.php'; $kernel = new Kernel([ 'legacy_enabled' => true, // Enable legacy support 'legacy_path' => __DIR__ . '/../' // Path to your existing files ]); $request = Request::createFromGlobals(); $response = $kernel->handle($request); $response->send();
That's it! Your existing application now has Canvas superpowers while everything continues to work exactly as before.
Using Canvas Services in Legacy Files
Now you can immediately start using Canvas services in your existing files:
<?php // legacy/users.php - existing file, now enhanced with Canvas use Quellabs\Canvas\Legacy\LegacyBridge; // Access Canvas services in legacy code $em = canvas('EntityManager'); $users = $em->findBy(User::class, ['active' => true]); // Use ObjectQuel for complex queries $recentUsers = $em->executeQuery(" range of u is App\\Entity\\User retrieve u where u.active = true and u.createdAt > :since sort by u.createdAt desc limit 10 ", ['since' => date('Y-m-d', strtotime('-30 days'))]); echo "Found " . count($users) . " active users<br>"; foreach ($recentUsers as $user) { echo "<h3>{$user->name}</h3>"; echo "<p>Joined: " . $user->createdAt->format('Y-m-d') . "</p>"; }
How Route Fallthrough Works
Canvas uses an intelligent fallthrough system that tries Canvas routes first, then automatically looks for corresponding legacy PHP files:
URL Request: /users/profile
1. Try Canvas route: /users/profile → ❌ Not found in Canvas controllers
2. Try legacy files:
- legacy/users/profile.php → ✅ Found! Execute this file
- legacy/users/profile/index.php → Alternative location
Examples:
- `/users` → `legacy/users.php` or `legacy/users/index.php`
- `/admin/dashboard` → `legacy/admin/dashboard.php`
- `/api/data` → `legacy/api/data.php`
Custom File Resolvers
If your legacy application has a different file structure, you can write custom file resolvers:
<?php // src/Legacy/CustomFileResolver.php use Quellabs\Canvas\Legacy\FileResolverInterface; class CustomFileResolver implements FileResolverInterface { public function resolve(string $path): ?string { // Handle WordPress-style routing if ($path === '/') { return $this->legacyPath . '/index.php'; } // Map URLs to custom file structure if (str_starts_with($path, '/blog/')) { $slug = substr($path, 6); return $this->legacyPath . "/wp-content/posts/{$slug}.php"; } // Handle custom admin structure if (str_starts_with($path, '/admin/')) { $adminPath = substr($path, 7); return $this->legacyPath . "/backend/modules/{$adminPath}.inc.php"; } return null; // Fall back to default behavior } } // Register with kernel $kernel->getLegacyHandler()->addResolver(new CustomFileResolver);
Legacy Preprocessing
Canvas includes preprocessing capabilities to handle legacy PHP files that use common patterns like header()
, die()
, and exit()
functions:
<?php // public/index.php $kernel = new Kernel([ 'legacy_enabled' => true, 'legacy_path' => __DIR__ . '/../legacy', 'legacy_preprocessing' => true // Default: enabled ]);
What preprocessing does:
- Converts
header()
calls to Canvas's internal header management - Transforms
http_response_code()
to Canvas response handling - Converts
die()
andexit()
calls to Canvas exceptions (maintains flow control)
Important limitation: Preprocessing only applies to the main legacy file, not to included/required files. Files included by legacy scripts may need refactoring if they use these functions.
Benefits of Legacy Integration
- 🚀 Zero Disruption: Existing URLs continue to work unchanged
- 🔧 Enhanced Legacy Code: Add Canvas services (ORM, caching, logging) to legacy files
- 🔄 Flexible Migration: Start with services, move to controllers, then to full Canvas
- 📈 Immediate Benefits: Better database abstraction, modern dependency injection, improved error handling
Advanced Features
Route Validation & Wildcards
class ProductController extends BaseController { /** * @Route("/products/{id:int}") */ public function show(int $id) { // Only matches numeric IDs } /** * @Route("/files/{path:**}") */ public function files(string $path) { // Matches: /files/css/style.css → path = "css/style.css" return $this->serveFile($path); } }
Available validators: int
, alpha
, alnum
, slug
, uuid
, email
Route Prefixes
/** * @RoutePrefix("/api/v1") */ class ApiController extends BaseController { /** * @Route("/users") // Actual route: /api/v1/users */ public function users() { return $this->json($this->em->findBy(User::class, [])); } }
Aspect-Oriented Programming
Canvas provides true AOP for controller methods, allowing you to separate crosscutting concerns from your business logic. Aspects execute at different stages of the request lifecycle.
Creating Aspects
Aspects implement interfaces based on when they should execute:
<?php namespace App\Aspects; use Quellabs\Contracts\AOP\BeforeAspect; use Quellabs\Contracts\AOP\AroundAspect; use Quellabs\Contracts\AOP\AfterAspect; use Symfony\Component\HttpFoundation\Response; // Before Aspects - Execute before the method, can stop execution class RequireAuthAspect implements BeforeAspect { public function __construct(private AuthService $auth) {} public function before(MethodContext $context): ?Response { if (!$this->auth->isAuthenticated()) { return new RedirectResponse('/login'); } return null; // Continue execution } } // Around Aspects - Wrap the entire method execution class CacheAspect implements AroundAspect { public function around(MethodContext $context, callable $proceed): mixed { $key = $this->generateCacheKey($context); if ($cached = $this->cache->get($key)) { return $cached; } $result = $proceed(); // Call the original method $this->cache->set($key, $result, $this->ttl); return $result; } } // After Aspects - Execute after the method, can modify response class AuditLogAspect implements AfterAspect { public function after(MethodContext $context, Response $response): void { $this->logger->info('Method executed', [ 'controller' => get_class($context->getTarget()), 'method' => $context->getMethodName(), 'user' => $this->auth->getCurrentUser()?->id ]); } }
Applying Aspects
Class-level aspects apply to all methods in the controller:
/** * @InterceptWith(RequireAuthAspect::class) * @InterceptWith(AuditLogAspect::class) */ class UserController extends BaseController { // All methods automatically get authentication and audit logging /** * @Route("/users") * @InterceptWith(CacheAspect::class, ttl=300) */ public function index() { // Gets: RequireAuth + AuditLog (inherited) + Cache (method-level) return $this->em->findBy(User::class, ['active' => true]); } }
Method-level aspects apply to specific methods:
class BlogController extends BaseController { /** * @Route("/posts") * @InterceptWith(CacheAspect::class, ttl=600) * @InterceptWith(RateLimitAspect::class, limit=100, window=3600) */ public function list() { // Method gets caching and rate limiting return $this->em->findBy(Post::class, ['published' => true]); } }
Aspect Parameters
Pass configuration to aspects through annotation parameters:
/** * @InterceptWith(CacheAspect::class, ttl=3600, tags={"reports", "admin"}) * @InterceptWith(RateLimitAspect::class, limit=10, window=60) */ public function expensiveReport() { // Cached for 1 hour with tags, rate limited to 10 requests per minute }
The aspect receives these as constructor parameters:
class CacheAspect implements AroundAspect { public function __construct( private CacheInterface $cache, private int $ttl = 300, private array $tags = [] ) {} }
Execution Order
Aspects execute in a predictable order:
- Before Aspects - Authentication, validation, rate limiting
- Around Aspects - Caching, transactions, timing
- After Aspects - Logging, response modification
Inherited Aspects
Build controller hierarchies with shared crosscutting concerns:
/** * @InterceptWith(RequireAuthAspect::class) * @InterceptWith(AuditLogAspect::class) */ abstract class AuthenticatedController extends BaseController { // Base authenticated functionality } /** * @InterceptWith(RequireAdminAspect::class) * @InterceptWith(RateLimitAspect::class, limit=100) */ abstract class AdminController extends AuthenticatedController { // Admin-specific functionality - inherits auth + audit } class UserController extends AdminController { /** * @Route("/admin/users") */ public function manage() { // Automatically inherits: RequireAuth + AuditLog + RequireAdmin + RateLimit return $this->em->findBy(User::class, []); } }
Form Validation
Canvas provides a powerful validation aspect that separates validation concerns from business logic:
<?php namespace App\Controllers; use Quellabs\Canvas\Annotations\Route; use Quellabs\Canvas\Annotations\InterceptWith; use Quellabs\Canvas\Validation\ValidateAspect; use App\Validation\UserValidation; class UserController extends BaseController { /** * @Route("/users/create", methods={"GET", "POST"}) * @InterceptWith(ValidateAspect::class, validate=UserValidation::class) */ public function create(Request $request) { if ($request->isMethod('POST')) { // Check validation results set by ValidateAspect if ($request->attributes->get('validation_passed', false)) { // Process valid form data $user = new User(); $user->setName($request->request->get('name')); $user->setEmail($request->request->get('email')); $user->setPassword(password_hash($request->request->get('password'), PASSWORD_DEFAULT)); $this->em->persist($user); $this->em->flush(); return $this->redirect('/users'); } // Validation failed - render form with errors return $this->render('users/create.tpl', [ 'errors' => $request->attributes->get('validation_errors', []), 'old' => $request->request->all() ]); } // Show empty form for GET requests return $this->render('users/create.tpl'); } }
Creating Validation Classes
<?php namespace App\Validation; use Quellabs\Canvas\Validation\ValidationInterface; use Quellabs\Canvas\Validation\Rules\NotBlank; use Quellabs\Canvas\Validation\Rules\Email; use Quellabs\Canvas\Validation\Rules\Length; class UserValidation implements ValidationInterface { public function getRules(): array { return [ 'name' => [ new NotBlank(['message' => 'Name is required']), new Length(['min' => 2, 'message' => 'Name must be at least {{min}} characters']) ], 'email' => [ new NotBlank(['message' => 'Email is required']), new Email(['message' => 'Please enter a valid email address']) ], 'password' => [ new NotBlank(['message' => 'Password is required']), new Length(['min' => 8, 'message' => 'Password must be at least {{min}} characters']) ] ]; } }
Auto-Response for APIs
Enable automatic JSON error responses for API endpoints:
/** * @Route("/api/users", methods={"POST"}) * @InterceptWith(ValidateAspect::class, validate=UserValidation::class, auto_respond=true) */ public function createUser(Request $request) { // For API requests, validation failures automatically return JSON: // { // "message": "Validation failed", // "errors": { // "email": ["Please enter a valid email address"], // "password": ["Password must be at least 8 characters"] // } // } // This code only runs if validation passes $user = $this->createUserFromRequest($request); return $this->json(['success' => true, 'user_id' => $user->getId()]); }
Contextual Services
Use different implementations based on context:
// Different template engines $twig = $this->container->for('twig')->get(TemplateEngineInterface::class); $blade = $this->container->for('blade')->get(TemplateEngineInterface::class); // Different cache backends $redis = $this->container->for('redis')->get(CacheInterface::class); $file = $this->container->for('file')->get(CacheInterface::class);
CLI Commands
Canvas includes a command-line interface called Sculpt for managing your application:
Route Management
# View all registered routes in your application ./vendor/bin/sculpt route:list ./vendor/bin/sculpt route:list --controller=UserController # Test which controller and method handles a specific URL path ./vendor/bin/sculpt route:match /users/123 ./vendor/bin/sculpt route:match GET /users/123 # Clear route cache ./vendor/bin/sculpt route:clear-cache
Asset Publishing
Canvas provides a powerful asset publishing system to deploy configuration files, templates, and other resources:
# List all available publishers ./vendor/bin/sculpt canvas:publish --list # Publish assets using a specific publisher ./vendor/bin/sculpt canvas:publish package:production # Overwrite existing files ./vendor/bin/sculpt canvas:publish package:production --overwrite # Skip confirmation prompts (for automated deployments) ./vendor/bin/sculpt canvas:publish package:production --force # Show help for a specific publisher ./vendor/bin/sculpt canvas:publish package:production --help
Key features:
- Safe publishing with automatic backup creation
- Transaction rollback if operations fail
- Interactive confirmation with preview of changes
- Extensible publisher system for custom deployment needs
Why Canvas?
- Legacy Integration: Works with existing PHP without breaking anything
- Zero Config: Start coding immediately with sensible defaults
- Clean Code: Annotations keep logic close to implementation
- Performance: Lazy loading, route caching, efficient matching
- Flexibility: Contextual containers and composable aspects
- Growth: Scales from simple sites to complex applications
Contributing
We welcome contributions! Here's how you can help improve Canvas:
Reporting Issues
- Use GitHub issues for bug reports and feature requests
- Include minimal reproduction cases
- Specify Canvas version and PHP version
Contributing Code
- Fork the repository
- Create a feature branch:
git checkout -b feature/amazing-feature
- Follow PSR-12 coding standards
- Add tests for new functionality
- Update documentation for new features
- Submit a pull request
License
Canvas is open-sourced software licensed under the MIT license.