Savv Web Framework
A zero-config, zero-build PHP engine for high-performance brand websites, portfolios, and public-facing web experiences. The speed of a static site, the power of a lean PHP core — without the build-tool tax.
The entire framework package is ~223 KB as of v2.1.0. Less than 1 MB total. You can trace the entire codebase in an afternoon.
Why Savv Web #
Modern PHP frameworks are powerful — but they carry enormous overhead when all you need is a clean, fast, presentation-first website. Savv Web was built to fill that gap without compromise.
| Feature | Savv Web | Full-Stack PHP | SSG |
|---|---|---|---|
| File-based routing — zero config | ✓ | ✗ | ✓ |
| No build tool required | ✓ | ✗ | ✗ |
| PWA built-in, no setup | ✓ | ✗ | ✗ |
| URL redirections, no settings | ✓ | ✗ | ✗ |
| Edit live in production | ✓ | ✗ | ✗ |
| Deploy on shared/budget hosting | ✓ | ⚠ | ✓ |
| CMS fallback support | ✓ | ✗ | ✗ |
Package Model #
Savv Web is split into two repositories to keep responsibilities clean.
Savv\. All source in
src/. Contains the bootstrapper, Router, Request, Response, Config, Validator,
Log, SystemController (PWA), Console Kernel, and all helpers. Install via Composer.
Quick Start #
Option A — Clone the starter (recommended)
git clone https://github.com/igefadele/savv_starter my-project cd my-project composer install
Point your server document root to public/ and open the browser.
Option B — Install the framework directly
composer require savadub/savv
Via GitHub VCS
{
"repositories": [{
"type": "vcs",
"url": "https://github.com/igefadele/savv_framework"
}],
"require": {
"savadub/savv": "dev-main"
}
}
Project Structure #
Bootstrap Flow #
define('ROOT_PATH', dirname(__DIR__));
define('PUBLIC_PATH', __DIR__);
require ROOT_PATH . '/vendor/autoload.php';
$app = \Savv\Core\Application::bootstrap(ROOT_PATH, PUBLIC_PATH);
$app->run();
Application::run() executes this sequence on every request:
- Route cache check — looks for
storage/framework/routes.php - Cache hit → loads routes via
Router::loadRawRoutes()(fast path) - Cache miss →
loadRouteFiles()loads internal framework routes (PWA), thenroutes/*.php, thenregisterRedirections() - Request capture —
Request::capture()snapshots superglobals - Dispatch —
Router::dispatch()matches explicit routes - Dynamic discovery —
resolveDynamicView()scansviews/pages/for GET requests - CMS fallback —
handleExternalFallbacks()checksconfigs/installations.php - 404 — renders
views/404.phpor a plain string
Routing #
1. File-Based Routing — Zero Configuration
The most common way to add a page. Place any .php file in views/pages/ and
it resolves as a URL with zero other steps:
views/pages/index.php → GET / views/pages/about.php → GET /about views/pages/services.php → GET /services views/pages/blog/post.php → GET /blog/post
Page parts (partials, sections, components) can live anywhere in views/. Just make
sure the main page file is in views/pages/ and imports them correctly.
2. Explicit Web Routes
router()->get('/', function () { require ROOT_PATH . '/views/pages/index.php'; }); router()->get('blog/{slug}', function ($slug) { require ROOT_PATH . '/views/pages/blog.php'; })->name('blog.show'); // Using the view() method router()->get('about', function () { return router()->view('pages/about'); });
3. API Routes
use App\Controllers\ContactController; router()->group(['prefix' => 'api', 'name' => 'api.'], function($router) { $router->post('contact-submit', [ContactController::class, 'submit']) ->name('submit.contact'); });
4. Named Routes & Parameters
// Generate URL from route name $url = route('blog.show', ['slug' => 'getting-started']); // → /blog/getting-started
5. Route Caching
Compile all routes into a static manifest for production performance:
php savv route:cache
# → storage/framework/routes.php generated
Delete storage/framework/routes.php to return to dynamic mode. Regenerate after
adding new pages or changing routes.
Views & Layouts #
$pageTitle = 'About — My Brand'; $pageDescription = 'Who we are and what we build.'; ob_start(); ?> <section> <h1>About Us</h1> <p>Our story here.</p> </section> <?php $content = ob_get_clean(); include ROOT_PATH . '/views/layouts/index.php';
Path helpers are also available:
view_path('pages/about.php'); // → /root/views/pages/about.php page_path('about.php'); // → /root/views/pages/about.php post_path('how-to-savv.md'); // → /root/views/posts/how-to-savv.md
The #savv ID
If you are not using the Savv Starter, wrap your main element with
id="savv" for SPA-feel navigation and fast page transitions to work. The Starter
does this for you in views/layouts/index.php.
<main id="savv" class="transition-fade"> <?php echo $content; ?> </main>
Blogging #
Any .md file placed in views/posts/ becomes a blog post accessible at
/{slug}. Each file must start with frontmatter:
--- title: My First Post slug: my-first-post date: 2026-04-17 author: Your Name status: published category: general --- # Post Content Write in Markdown here.
Only posts with status: published are publicly accessible. Register slugs in
configs/posts.php:
return [ 'my-first-post' => 'My First Post', ];
Use views/pages/posts.php for the blog listing and
views/pages/post-detail.php for individual posts.
Middleware #
Define aliases in configs/middlewares.php, then apply them to routes or groups.
return [ 'auth' => \App\Middleware\Authenticate::class, ];
// Apply to a group router()->group(['prefix' => 'dashboard', 'middleware' => 'auth'], function ($r) { $r->get('overview', function() { require ROOT_PATH . '/views/pages/dashboard.php'; }); }); // Apply to a single route router()->post('contact', [ContactController::class, 'submit']) ->middleware('auth')->name('submit.contact');
Writing a middleware
namespace App\Middleware; use Savv\Utils\Request; class Authenticate { public function handle(Request $request, callable $next) { if (!isset($_SESSION['user_id'])) { return response()->redirect('/login'); } return $next($request); } }
PWA — Built In, No Action Required #
The framework self-registers two routes on every boot:
GET /manifest.json → SystemController::getManifestFile() GET /sw.js → SystemController::getServiceWorkerFile()
Configure everything in configs/pwa.php:
return [ 'name' => 'My Brand', 'short_name' => 'Brand', 'version' => 'v1', // Bump to bust the SW cache 'theme_color' => '#081065', 'background_color' => '#ffffff', 'display' => 'standalone', 'icons' => [ ['src' => '/assets/images/icons/icon-192x192.png', 'sizes' => '192x192'], ['src' => '/assets/images/icons/icon-512x512.png', 'sizes' => '512x512'], ], 'precache' => ['/', '/offline', '/assets/css/main.css'], ];
That is the only step. The manifest, service worker, and offline fallback are all handled by the framework.
Layout Helpers #
savv_head()
Call inside <head>. Injects: PWA manifest link, theme-color meta, Apple touch
icon, Bootstrap 5 CSS, Bootstrap Icons CSS, and AOS CSS.
<head>
<meta charset="UTF-8">
<title><?= $pageTitle ?></title>
<?php savv_head(); ?>
</head>
savv_scripts()
Call before </body>. Injects: Bootstrap 5 JS, AOS (auto-initialized), Swup with
HeadPlugin and ScrollPlugin, PWA service worker registration, counter animations, and the
savv:init event dispatcher.
<?php savv_scripts(); ?> <script src="/assets/js/main.js"></script> </body>
The savv:init Event #
Savv dispatches a custom browser event called savv:init every time a page is initialized
— on initial load and after every Swup page transition.
savv:init = "The page is ready. Run your UI logic now."
Use it instead of
DOMContentLoaded for any DOM-dependent code.
// Runs on initial load AND after every page swap const myAppLogic = () => { // Initialize sliders, counters, tooltips, etc. const counters = document.querySelectorAll('.counter-element'); // ... your component logic }; document.addEventListener('savv:init', myAppLogic);
| Event | Fires When | Use Case |
|---|---|---|
| DOMContentLoaded | Once on initial load | Traditional multi-page sites |
| savv:init | Every load + every page swap | Savv-powered applications |
Global Helper Functions #
All helpers are auto-loaded via src/Helpers/helpers.php.
request()
request() // Request singleton request('name', 'Guest') // input with default request()->only(['name', 'email']) // subset request()->all() // merged POST + GET request()->post('field') // POST only request()->query('page') // GET only request()->method() // 'GET', 'POST', etc. request()->path() // '/about' request()->filled('email') // bool — '0' counts as filled request()->ajax() // bool — X-Requested-With check
response()
response('<h1>Hello</h1>', 200) response()->json(['status' => 'success'], 201) response()->redirect('/thank-you') response()->redirect('/new-url', 301) response()->header('X-Powered-By', 'Savv') response()->view('pages/about', ['title' => 'About'])
config()
config('mail.smtp.host') config('pwa.theme_color') config('pwa.version') config('redirections.facebook')
validate()
Validates and terminates with 422 JSON on failure. Returns only declared keys on
success.
$validated = validate(request()->all(), [ 'name' => 'required', 'email' => 'required|email', 'message' => 'required|min:10|max:2000', 'budget' => 'numeric', 'website' => 'url', ]); // Rules: required, email, min:n, max:n, numeric, url
logger()
logger('Form submitted', ['email' => request('email')]); // info logger()->error('Mail failed', ['reason' => $e->getMessage()]); logger()->warning('Slow query', ['ms' => 850]); logger()->debug('Route matched', ['path' => request()->path()]); // Writes to: storage/logs/YYYY-MM-DD.log // [2026-04-17 14:30:01] INFO: Form submitted {"email":"..."}
Core Utility Classes #
Savv\Utils\Request
| Method | Description |
|---|---|
| capture() | Static factory — builds instance from superglobals |
| input($key, $default) | POST precedence over GET |
| all() | Merged GET + POST |
| post($key, $default) | POST data only |
| only(array $keys) | Subset of inputs |
| except(array $keys) | All inputs minus excluded keys |
| filled($key) | Non-empty check — '0' and 0 count as filled |
| query($key, $default) | Query string values |
| method() | HTTP method string |
| path() | Request path without query string |
| ajax() | Detects X-Requested-With: XMLHttpRequest |
Savv\Utils\Response
| Method | Description |
|---|---|
| setStatus(int $code) | Set HTTP status code |
| header($key, $value) | Add a response header |
| json(array $data, int $status) | JSON response with correct Content-Type |
| redirect(string $url, int $status) | HTTP redirect — default 302 |
| view(string $viewPath, array $data) | Render a PHP view into the content buffer |
| send() | Output status code, headers, and body |
Savv\Utils\Log
Static methods: info(), error(), warning(),
debug(). Writes to storage/logs/YYYY-MM-DD.log. Directory is created
automatically. Uses FILE_APPEND | LOCK_EX for safe concurrent writes.
Savv\Utils\Router
Singleton. Supports GET, POST, named routes, route parameters ({slug}), route groups
with prefix/name/middleware inheritance, middleware pipeline, dynamic view discovery, and
serializable route cache.
URL Redirections #
return [ 'fb' => 'https://facebook.com/yourpage', // 302 'careers' => ['url' => 'https://jobs.example.com', 'status' => 301], ];
yourdomain.com/fb redirects automatically. No controller. No route file edit. Registered
at bootstrap by Router::registerRedirections().
External CMS Fallback #
Run WordPress, WooCommerce, or any PHP app alongside Savv under the same domain — no server rewrites needed.
return [ 'wordpress' => [ 'active' => true, 'path' => '/var/www/wordpress/wp-blog-header.php', ], 'ecommerce' => [ 'active' => false, 'path' => '/var/www/shop/index.php', ], ];
When no Savv route matches, handleExternalFallbacks() iterates this config and hands off
to the first active installation. Custom 404 views are supported at views/404.php.
CLI Commands #
php savv <command> [arguments]
| Command | Description |
|---|---|
| make:controller <name> | Scaffolds a controller class in app/Controllers/ |
| make:config <name> | Generates a blank config file in configs/ |
| cache:route | Compiles all routes into storage/framework/routes.php |
| cache:routes | Alias for cache:route |
| cache:page <uri> | Renders and caches a single page to storage/framework/pages/ |
| cache:pages | Renders and caches all pages discovered in views/pages/ |
| cache:post <slug> | Renders and caches a single post to storage/framework/posts/ |
| cache:posts | Renders and caches all posts registered in configs/posts.php |
| sync:post <slug> | Reads a post's frontmatter and writes its record to configs/posts.php |
| sync:posts | Scans all Markdown files in views/posts/ and rebuilds configs/posts.php |
| optimize | Runs all caching steps in sequence: routes → pages → sync posts → cache posts |
| bus:work | Starts the blocking Redis queue worker that processes cross-service bus packets |
cache:route compiles: explicit routes, file-based view routes from
views/pages/, redirections, and posts. Delete storage/framework/routes.php to return to dynamic mode. Run optimize in one command to cache routes, pages, and posts all at once before a production deployment.
Configuration Reference #
configs/pwa.php
return [ 'name' => 'My App', 'short_name' => 'App', 'version' => 'v1', // bump to bust SW cache 'theme_color' => '#000000', 'background_color' => '#ffffff', 'display' => 'standalone', 'icons' => [...], 'precache' => ['/', '/offline', '/assets/css/main.css'], ];
configs/mail.php
return [ 'smtp' => [ 'host' => $_ENV['SMTP_HOST'] ?? null, 'port' => $_ENV['SMTP_PORT'] ?? null, 'user' => $_ENV['SMTP_USER'] ?? null, 'password' => $_ENV['SMTP_PASSWORD'] ?? null, 'security' => 'tls', 'from' => $_ENV['SMTP_FROM'] ?? null, 'to' => $_ENV['SMTP_TO'] ?? null, ], ];
configs/database.php
Required when using the database layer. Add this file to your project and pass it to SavvDb::getInstance() during bootstrap. Add a redis key when you want the bus provider to activate automatically.
return [ 'is_active' => true, 'driver' => 'mysql', 'host' => '127.0.0.1', 'database' => 'savv_db', 'username' => $_ENV['DB_USERNAME'] ?? 'root', 'password' => $_ENV['DB_PASSWORD'] ?? '', 'charset' => 'utf8mb4', 'collation' => 'utf8mb4_unicode_ci', 'redis' => [ 'is_active' => true, 'host' => '127.0.0.1', 'port' => 6379, 'password' => null, ], ];
Initialize once at bootstrap — typically in a service provider or at the top of public/index.php:
use Savv\Utils\Db\SavvDb; SavvDb::getInstance(config('database'));
Database #
Savv Web includes a lightweight, high-performance database layer built on four tightly designed classes. It gives you a modern ORM experience — fluent querying, eager loading, dirty-state tracking, and relationships — while adding negligible overhead and keeping the entire implementation readable and traceable.
The database layer lives under Savv\Utils\Db\ and is available via global helpers (savvQuery(), savvDb()) in addition to static model methods.
Architecture
| Class | Responsibility |
|---|---|
| SavvDb | Singleton PDO connection manager. All queries go through prepared statements. |
| SavvModel | Abstract base class for your models. Provides CRUD, dirty-state tracking, and relationship descriptors. |
| SavvQuery | Fluent query builder. Handles filtering, ordering, pagination, joins, eager loading, and model hydration. |
| SavvCache | In-memory identity map. Caches meta-data during the request lifecycle to prevent redundant queries. |
Blueprint Relationships. Relationship methods (hasMany, belongsTo, etc.) do not execute queries immediately. They return a descriptor array that the eager-loading engine uses to batch all related records into a single query per relationship. Database load drops from O(N) to O(1 + number of relations).
Dirty State Tracking. SavvModel stores the original state at load time. On save(), only columns that actually changed are sent to the database. After a successful save, the original state is reset, preventing redundant identical writes.
Explicit Hydration. SavvQuery::setModel() tells the builder exactly which class to instantiate per row. No convention guessing. Full type safety.
Defining Models
Extend SavvModel and declare the $table property:
namespace App\Models; use Savv\Utils\Db\SavvModel; class Post extends SavvModel { protected static $table = 'posts'; public function author() { return $this->belongsTo(User::class, 'user_id'); } public function comments() { return $this->hasMany(Comment::class, 'post_id'); } }
CRUD Operations
// Find by ID $post = Post::find(1); echo $post->title; // Create $user = new User(['username' => 'ige_fadele', 'email' => 'ige@savadub.com']); $user->save(); // $user->id is now populated // Update — only changed columns are sent to the DB $user = User::find(1); $user->status = 'inactive'; $user->save(); // Delete $user->delete();
Fluent Querying
// Via model static method $users = User::query() ->select(['id', 'username', 'email']) ->where('status', 'active') ->orderBy('created_at', 'DESC') ->get(); // Via global helper — works on any table $posts = savvQuery('posts') ->where('is_published', 1) ->orderBy('created_at', 'DESC') ->get();
| Method | Description |
|---|---|
| select($columns) | Specify columns to fetch. Accepts a string or array. |
| where($column, $value, $op) | Add a WHERE clause. Default operator is =. |
| whereIn($column, $values) | Add a WHERE IN clause. |
| orderBy($column, $direction) | Set ORDER BY. Default direction is DESC. |
| join($table, $first, $second, $type) | Add a JOIN. Default type is INNER. |
| get() | Execute and return all matched model instances. |
| first() | Execute and return the first matched result only. |
| count() | Return the count of matched rows as an integer. |
| exists() | Return true if at least one matching row exists. |
| paginate($perPage, $page) | Return a paginated result array. |
Pagination
$result = User::query() ->where('status', 'active') ->paginate(15, $_GET['page'] ?? 1); // Returns: // [ // 'data' => [...], // model instances // 'total' => 120, // 'per_page' => 15, // 'current_page' => 1, // 'last_page' => 8, // ]
Eager Loading
Load relationships upfront to avoid the N+1 problem. Savv fetches all related records in one additional query per relationship, regardless of how many parent models are in the result.
// 2 queries total: one for posts, one for their authors $posts = Post::query() ->with(['author']) ->get(); foreach ($posts as $post) { echo $post->author->name; // no extra query triggered } // Multiple relationships — still one extra query per relation $posts = Post::query() ->with(['author', 'comments']) ->get();
Relationships
hasOne — One-to-One
public function profile() { return $this->hasOne(Profile::class, 'user_id'); } $profile = User::find(1)->profile;
hasMany — One-to-Many
public function comments() { return $this->hasMany(Comment::class, 'post_id'); } $comments = Post::find(1)->comments;
belongsTo — Inverse / Many-to-One
public function post() { return $this->belongsTo(Post::class, 'post_id'); } $post = Comment::find(1)->post;
hasManyThrough — Deep Relationships
For structures like Country → Users → Posts. Uses a standard INNER JOIN and returns a blueprint the eager-loading engine can batch.
// In Country model public function posts() { return $this->hasManyThrough( Post::class, // target User::class, // intermediate 'country_id', // FK on users table → Country 'user_id' // FK on posts table → User ); } $posts = Country::find(1)->posts;
Raw Queries & Transactions
// Raw query with bound parameters savvDb()->query("UPDATE sessions SET expired = 1 WHERE last_seen < ?", [time() - 3600]); // Transaction $db = savvDb(); $db->query("START TRANSACTION"); try { $db->query("INSERT INTO orders (user_id, total) VALUES (?, ?)", [$userId, $total]); $db->query("UPDATE inventory SET stock = stock - 1 WHERE product_id = ?", [$productId]); $db->query("COMMIT"); } catch (\Exception $e) { $db->query("ROLLBACK"); logger()->error('Transaction failed', ['reason' => $e->getMessage()]); }
Identity Map — Meta Data
SavvCache is used internally by SavvQuery::getWithMeta() to batch-fetch meta records from a {table}_meta table — useful for WordPress-style architectures where entities have a separate meta table.
// Fetch records + meta in 2 queries total, not N+1 $items = savvQuery('users')->getWithMeta([1, 2, 3]); // Access meta via __get — hits cache, not DB echo $user->display_name; // Write or read the cache directly use Savv\Utils\Db\SavvCache; SavvCache::setMeta($userId, 'avatar_url', '/uploads/avatar.jpg'); $avatar = SavvCache::getMeta($userId, 'avatar_url'); SavvCache::flush(); // Free memory after long-running processes
Global Database Helpers
| Helper | Returns | Description |
|---|---|---|
| savvQuery($table) | SavvQuery | Start a fluent query on any table. |
| savvDb() | SavvDb | Access the raw PDO wrapper for queries and transactions. |
All queries — including every query generated by the builder and the model — use PDO prepared statements with bound parameters. SQL injection protection is on by default with no extra configuration needed.
Events, Observers & Bus #
Savv ships a three-layer reactive system that scales from simple in-process callbacks to cross-service messaging across independent applications — all without adding a queue manager or a separate container.
| Layer | Class | Scope | Requires |
|---|---|---|---|
| SavvEvent | Savv\Utils\Event\SavvEvent | In-process. Fires and listens within the same request lifecycle. | Nothing |
| SavvObserver | Savv\Utils\Event\SavvObserver | Groups model lifecycle listeners into a single class per model. | Nothing |
| SavvBus | Savv\Utils\Bus\SavvBus | Cross-service. Publishes events onto a shared Redis queue consumed by worker processes. | Redis |
SavvEvent — In-Process Dispatcher
SavvEvent is the framework's in-memory event bus. Register listeners with listen() and fire them with fire(). Listeners execute immediately, synchronously, in the same PHP process. Returning false from any listener halts the chain.
use Savv\Utils\Db\SavvEvent; // Register a listener SavvEvent::listen('order.placed', function ($payload) { logger('Order placed', ['id' => $payload['order_id']]); }); // Fire the event — all matching listeners run immediately SavvEvent::fire('order.placed', [ 'order_id' => 42, 'total' => 199.99, ]); // Returning false from a listener halts the chain SavvEvent::listen('order.placed', function ($payload) { if ($payload['total'] < 0) return false; // stops further listeners });
Model Lifecycle Hooks
SavvModel fires SavvEvent events automatically at key points in a model's lifecycle. Hook into them with static convenience methods — no manual event wiring needed.
// Fires before the record is inserted. Return false to abort the save. User::creating(function ($user) { $user->password = password_hash($user->password, PASSWORD_BCRYPT); }); // Fires after the record is inserted. User::created(function ($user) { SavvBus::dispatch('broadcast:user.created', ['id' => $user->id]); }); // Fires before the record is updated. Return false to abort. User::updating(function ($user) { /* ... */ }); // Fires after the record is updated. User::updated(function ($user) { /* ... */ }); // Fires before the record is deleted. Return false to abort. User::deleting(function ($user) { /* ... */ }); // Fires after the record is deleted. User::deleted(function ($user) { /* clear caches, send notifications */ }); // Generic hook for any custom event name User::on('approved', function ($user) { /* ... */ }); // Trigger a custom event manually on a model instance $user->trigger('approved');
Lifecycle hooks that return false (creating, updating, deleting) abort the operation entirely — the database write never happens. This makes them the correct place for validation, permission checks, or business rule enforcement.
Cross-Service Event Intake
Events arriving from other Savv services over the bus are re-fired locally with a bus: prefix. Use plain event names for internal flow and bus:-prefixed names for inter-service events.
// Listen for an event sent from another Savv application via the bus SavvEvent::listen('bus:user.created', function ($payload) { // React in this application's context logger('Remote user created', ['id' => $payload['id']]); });
SavvEvent API
| Method | Description |
|---|---|
| SavvEvent::listen($event, $callback) | Register a listener for an event name. Multiple listeners can share one name. |
| SavvEvent::fire($event, $payload) | Run all listeners for the event. Returns false if any listener returns false, otherwise true. |
Savv Observer #
SavvObserver gives you a single class to own all lifecycle reactions for a model. Instead of scattering User::created() callbacks across controllers and service files, you centralize them in one observe() method and Savv boots the observer automatically at startup.
1. Register in Config
return [ \App\Models\User::class => \App\Observers\UserObserver::class, \App\Models\Order::class => \App\Observers\OrderObserver::class, ];
The application reads configs/observers.php during Application::run() and calls observe() on each listed class. The observer is active for the entire request lifecycle.
2. Write the Observer Class
namespace App\Observers; use App\Models\User; use Savv\Utils\Bus\SavvBus; use Savv\Utils\Db\SavvEvent; use Savv\Utils\Db\SavvObserver; class UserObserver extends SavvObserver { public function observe(): void { // Hash the password before a new user is inserted User::creating(function ($user) { $user->password = password_hash($user->password, PASSWORD_BCRYPT); }); // Broadcast to other services when a user is created User::created(function ($user) { SavvBus::dispatch('broadcast:user.created', [ 'id' => $user->id, 'email' => $user->email, ]); }); // Log every deletion for audit trails User::deleted(function ($user) { logger()->info('User deleted', ['id' => $user->id]); }); // Listen for the same event arriving from another service over the bus SavvEvent::listen('bus:user.created', function ($payload) { // React within this application's context }); } }
Observers are the natural place to trigger bus dispatches, update caches, send notifications, or write audit logs — keeping that logic out of controllers and models entirely.
Savv Bus Service #
Savv\Utils\Bus\SavvBus is the framework's transport layer for cross-service communication. It lets independent Savv applications publish events onto a shared Redis queue so other services can receive and react — asynchronously, without any direct coupling between codebases.
How It Works
- Your app dispatches an event with
SavvBus::dispatch(). - The payload is serialized as JSON and pushed onto the shared Redis list
savv_global_bus. - A long-running
bus:workworker on the receiving service pops the packet using a blocking read. - The worker re-fires the event locally as
bus:{event}throughSavvEvent::fire(). - Any
SavvEvent::listen('bus:{event}', ...)registered in that service reacts.
The bus activates automatically when a valid database.redis key exists in configs/database.php and is_active is true. Without that, the framework runs normally — SavvBus::dispatch() returns false silently and no error is thrown.
Redis Configuration
return [ 'driver' => 'mysql', 'host' => '127.0.0.1', 'database' => 'savv_db', // ... other db settings 'redis' => [ 'is_active' => true, // Set to false to disable the bus 'host' => '127.0.0.1', 'port' => 6379, 'password' => null, // Leave null if no auth required ], ];
Savv prefers the native Redis PHP extension and falls back to Predis\Client automatically. Install one via PECL or add predis/predis via Composer.
Auto-Broadcasting Events
The BusServiceProvider registers a wildcard listener for all events prefixed with broadcast: and automatically pushes them onto the bus. This means any event fired as broadcast:something is dispatched cross-service without any manual SavvBus::dispatch() call.
// Prefixing with 'broadcast:' dispatches to the bus automatically SavvEvent::fire('broadcast:invoice.paid', [ 'invoice_id' => 501, 'customer_id' => 88, 'amount' => 45000, ]); // Or dispatch directly when you want explicit control use Savv\Utils\Bus\SavvBus; SavvBus::dispatch('invoice.paid', [ 'invoice_id' => 501, 'customer_id' => 88, 'amount' => 45000, ]);
Each packet sent over the bus includes: the event name, the application name from config('app.name'), the payload array, and a Unix timestamp. The receiving service's worker re-fires the packet as bus:invoice.paid locally.
Running the Bus Worker
php /path/to/project/savv bus:work # Output on each received packet: # Savv Bus Worker listening... # [2026-05-06 14:23:01] Received: user.created from AuthApp
The worker blocks on the Redis list using brPop with an indefinite timeout. One app publishes user.created; any listening service worker receives it as bus:user.created and fires it into that service's local event system. No shared code. No HTTP calls. No tight coupling.
Full Cross-Service Flow Example
// In Service A's UserObserver or controller User::created(function ($user) { SavvBus::dispatch('user.created', ['id' => $user->id, 'email' => $user->email]); });
// In Service B — reacts to the event from Service A SavvEvent::listen('bus:user.created', function ($payload) { // Create a matching profile record in Service B's database $profile = new Profile(['user_id' => $payload['id']]); $profile->save(); });
Keeping the Worker Running — Supervisor
[program:savv-bus-worker] process_name=%(program_name)s_%(process_num)02d command=php /path/to/project/savv bus:work autostart=true autorestart=true user=www-data numprocs=1 redirect_stderr=true stdout_logfile=/path/to/project/storage/logs/bus-worker.log
If your environment does not provide Redis, skip the worker entirely. The rest of Savv — routing, ORM, PWA, in-process events — all continue to function normally. Redis is purely opt-in.
Deployment #
- Point your server document root to
public/ - Keep all application files above the public web root
- Ensure
storage/logs/andstorage/framework/are writable by the web server - Provide all required values in
.envfor production - Run
php savv optimizebefore going live (caches routes, pages, and posts in one step) - Server block samples for Apache, Nginx, Caddy, and LiteSpeed are in the starter
at
public/server-block-samples/
Autoloading #
{
"autoload": {
"psr-4": { "App\\": "app/" },
"files": [ "app/helpers.php" ]
}
}
composer dump-autoload
Philosophy #
Most websites do not need a full-stack framework. They need clean routing, a request/response model, config management, validation, and a sensible structure — and they need to be fast, deployable anywhere, and editable without a build pipeline.
Savv Web delivers exactly that. Nothing more, nothing less. It feels familiar to developers coming from Laravel conventions while remaining readable enough that someone new to frameworks can trace the entire codebase in an afternoon.
Savv Web is for developers who value readability, directness, and control — without the ceremony.