v2.5.3 GitHub Get Starter Project
v2.5.3 · ~223 KB · MIT

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.

Framework savadub/savv
The installable core package. Namespace: Savv\. All source in src/. Contains the bootstrapper, Router, Request, Response, Config, Validator, Log, SystemController (PWA), Console Kernel, and all helpers. Install via Composer.
Starter savv_starter
The ready-to-use project skeleton. Already depends on the framework. Provides the application structure, configs, layouts, views, partials, and example pages. Recommended starting point.

Quick Start #

Option A — Clone the starter (recommended)

terminal
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

terminal
composer require savadub/savv

Via GitHub VCS

composer.json
{
  "repositories": [{
    "type": "vcs",
    "url": "https://github.com/igefadele/savv_framework"
  }],
  "require": {
    "savadub/savv": "dev-main"
  }
}

Project Structure #

my-savv-app/ ├── app/ │ ├── Controllers/ │ └── Middleware/ ├── configs/ │ ├── mail.php │ ├── middlewares.php │ ├── posts.php │ ├── pwa.php │ ├── redirections.php │ └── installations.php # CMS/app integrations ├── public/ # ← web server document root │ └── index.php # 3-line entry point ├── routes/ │ ├── web.php │ └── api.php ├── storage/ │ ├── framework/ │ │ └── routes.php # generated route cache │ └── logs/ ├── views/ │ ├── layouts/ │ │ └── index.php │ ├── pages/ # file-based routing root │ │ ├── index.php # → / │ │ ├── about.php # → /about │ │ └── offline.php # → /offline (PWA) │ ├── partials/ │ └── posts/ # markdown post files └── .env

Bootstrap Flow #

public/index.php
    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:

  1. Route cache check — looks for storage/framework/routes.php
  2. Cache hit → loads routes via Router::loadRawRoutes() (fast path)
  3. Cache missloadRouteFiles() loads internal framework routes (PWA), then routes/*.php, then registerRedirections()
  4. Request captureRequest::capture() snapshots superglobals
  5. DispatchRouter::dispatch() matches explicit routes
  6. Dynamic discoveryresolveDynamicView() scans views/pages/ for GET requests
  7. CMS fallbackhandleExternalFallbacks() checks configs/installations.php
  8. 404 — renders views/404.php or 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:

file-to-url mapping
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

routes/web.php
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

routes/api.php
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

php
// 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:

terminal
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 #

views/pages/about.php
$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:

php
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.

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:

views/posts/my-post.md
---
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:

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.

configs/middlewares.php
return [
    'auth' => \App\Middleware\Authenticate::class,
];
routes/web.php
// 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

app/Middleware/Authenticate.php
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:

auto-registered routes
GET /manifest.json  →  SystemController::getManifestFile()
GET /sw.js          →  SystemController::getServiceWorkerFile()

Configure everything in configs/pwa.php:

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.

views/partials/head.php
<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.

views/partials/scripts.php
    <?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.

assets/js/main.js
// 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()

php
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()

php
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()

php
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.

php
$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()

php
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 #

configs/redirections.php
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.

configs/installations.php
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 #

terminal
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

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

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.

configs/database.php
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:

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

ClassResponsibility
SavvDbSingleton PDO connection manager. All queries go through prepared statements.
SavvModelAbstract base class for your models. Provides CRUD, dirty-state tracking, and relationship descriptors.
SavvQueryFluent query builder. Handles filtering, ordering, pagination, joins, eager loading, and model hydration.
SavvCacheIn-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:

app/Models/Post.php
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

php
// 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

php
// 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();
MethodDescription
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

php
$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.

php
// 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

php
public function profile() {
    return $this->hasOne(Profile::class, 'user_id');
}
$profile = User::find(1)->profile;

hasMany — One-to-Many

php
public function comments() {
    return $this->hasMany(Comment::class, 'post_id');
}
$comments = Post::find(1)->comments;

belongsTo — Inverse / Many-to-One

php
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.

php
// 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

php
// 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.

php
// 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

HelperReturnsDescription
savvQuery($table)SavvQueryStart a fluent query on any table.
savvDb()SavvDbAccess 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.

LayerClassScopeRequires
SavvEventSavv\Utils\Event\SavvEventIn-process. Fires and listens within the same request lifecycle.Nothing
SavvObserverSavv\Utils\Event\SavvObserverGroups model lifecycle listeners into a single class per model.Nothing
SavvBusSavv\Utils\Bus\SavvBusCross-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.

php
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.

php — available hooks
// 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.

php
// 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

MethodDescription
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

configs/observers.php
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

app/Observers/UserObserver.php
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

  1. Your app dispatches an event with SavvBus::dispatch().
  2. The payload is serialized as JSON and pushed onto the shared Redis list savv_global_bus.
  3. A long-running bus:work worker on the receiving service pops the packet using a blocking read.
  4. The worker re-fires the event locally as bus:{event} through SavvEvent::fire().
  5. 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

configs/database.php
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.

php — auto-broadcast shortcut
// 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

terminal
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

Service A — publishes the event
// In Service A's UserObserver or controller
User::created(function ($user) {
    SavvBus::dispatch('user.created', ['id' => $user->id, 'email' => $user->email]);
});
Service B — listens for it
// 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

supervisor.conf
[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/ and storage/framework/ are writable by the web server
  • Provide all required values in .env for production
  • Run php savv optimize before 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 #

composer.json
{
  "autoload": {
    "psr-4": { "App\\": "app/" },
    "files": [ "app/helpers.php" ]
  }
}
terminal
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.