folivoro / sloth
A WordPress theme framework built with Laravel components
Requires
- php: >=8.4
- ext-json: *
- bitandblack/image-information: ^3.2
- brain/hierarchy: ^2.0 || ^3.0
- composer/composer: ^2.9
- filp/whoops: ^2.18
- illuminate/cache: ^12.0
- illuminate/config: ^12.0
- illuminate/console: ^12.0
- illuminate/container: ^12.0
- illuminate/database: ^12.0
- illuminate/events: ^12.0
- illuminate/filesystem: ^12.56
- illuminate/http: ^12.58
- illuminate/pagination: ^12.0
- illuminate/support: ^12.0
- illuminate/validation: ^12.0
- illuminate/view: ^12.0
- inpsyde/wp-context: ^1.5
- jgrossi/corcel: ^9.0
- johnbillion/args: ^2.4
- johnbillion/extended-cpts: ^5.1
- nunomaduro/termwind: ^2.4
- php-debugbar/php-debugbar: ^3.7
- spatie/image: ^1.10 || ^2.0 || ^3.0
- symfony/filesystem: ^8.0
- symfony/process: ^7.4
- symfony/routing: ^8.0
- twig/twig: ^3.0
- vlucas/phpdotenv: ^2.6 || ^3.0 || ^5.0
- wp-cli/wp-cli: ^2.12
Requires (Dev)
- brain/monkey: ^2.6
- captainhook/captainhook: ^5.29
- friendsofphp/php-cs-fixer: ^3.50
- pestphp/pest: ^2.0
- pestphp/pest-plugin: ^2.0
- php-stubs/acf-pro-stubs: ^6.5
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^10.0
- ramsey/conventional-commits: ^1.7
- rector/rector: ^2.0
- spaze/phpstan-disallowed-calls: ^4.12
- szepeviktor/phpstan-wordpress: ^2.0
- unh3ck3d/php-cs-fixer-git-hook: ^1.0
Suggests
- ext-imagick: Required for generating screenshot.png during theme setup
This package is auto-updated.
Last update: 2026-05-14 11:07:46 UTC
README
Sloth — WordPress Theme Framework
A modern WordPress theme framework built with Laravel components, designed for developers who want to build powerful WordPress themes with a clean, object-oriented architecture.
Features
- Laravel Components: Container, Validation, Pagination, View, Cache, Events and more — without the full Laravel stack
- Symfony Routing: Laravel-esque custom routing backed by
symfony/routing— zero extra dependencies - HTTP Layer:
Response::make(),Response::json(),Response::redirect()and more - ACF Support: Seamless integration with Advanced Custom Fields
- Module System: Organized module-based architecture for theme components
- Template Hierarchy:
brain/hierarchyintegration for intelligent template loading - WordPress REST API: Easy API endpoint creation with auto-discovery
- DebugBar: PHP DebugBar integration for development
- WP-CLI: Artisan-style console commands via
wp sloth
Requirements
- PHP: 8.4 or higher
- WordPress: 5.0 or higher
- Composer: 2.0 or higher
Installation
composer create-project folvioro/sloth my-theme
Quick Start
1. Theme Activation
Activate the theme in WordPress admin. Sloth automatically bootstraps and registers all service providers.
2. Defining Routes
Create app/routes/web.php or theme/routes/web.php:
<?php use Sloth\Facades\Route; use Sloth\Http\Response; // Basic route Route::get('/css/products', function () { return Response::make(view('styles.index'), 200) ->header('Content-Type', 'text/css'); })->name('product-styles'); // Route with parameters Route::get('/posts/{slug}', function (string $slug) { return Response::make(view('single', compact('slug'))); })->name('post.show'); // JSON response Route::get('/api/status', function () { return Response::json(['status' => 'ok']); }); // Redirect Route::get('/old-path', function () { Response::redirect('/new-path'); });
Custom routes run on template_redirect at priority 1 — before WordPress template loading. If no route matches,
WordPress handles the request normally.
Note: Custom routes are for non-WordPress URLs like
/css/products,/sitemap.xml, or/api/custom. Do not register routes that conflict with WordPress template URLs unless you intentionally want to override them.
3. Using Models
<?php use App\Model\NewsModel; // Get a post by ID $post = NewsModel::find(123); // Query posts $posts = NewsModel::published() ->orderBy('post_date', 'DESC') ->limit(10) ->get();
4. Using the View System
<?php use Sloth\Facades\View; // Render a Twig view View::make('partials.header', ['title' => 'Welcome']);
5. Validation
<?php use Illuminate\Support\Facades\Validator; $validator = Validator::make($data, [ 'name' => 'required|min:3|max:255', 'email' => 'required|email', ]); if ($validator->fails()) { $errors = $validator->errors(); }
Routing
Sloth's router is backed by symfony/routing and provides a Laravel-esque API. Routes are loaded from
app/routes/web.php and theme/routes/web.php — both are optional.
HTTP Methods
Route::get('/path', fn() => Response::make('hello')); Route::post('/path', fn() => Response::json(['ok' => true])); Route::put('/path', fn() => Response::noContent()); Route::delete('/path', fn() => Response::noContent());
Route Parameters
Route::get('/posts/{slug}', function (string $slug) { return Response::make(view('single', compact('slug'))); }); Route::get('/archive/{year}/{month}', function (string $year, string $month) { return Response::make(view('archive', compact('year', 'month'))); });
Named Routes & URL Generation
Route::get('/posts/{slug}', fn($slug) => Response::make(view('single')))->name('post.show'); // Generate URL via facade URL::route('post.show', ['slug' => 'hello-world']); // → https://example.com/posts/hello-world // Or via helper url()->route('post.show', ['slug' => 'hello-world']);
HTTP Response
Sloth\Http\Response extends Illuminate\Http\Response with static factory methods.
use Sloth\Http\Response; // HTML response Response::make('<h1>Hello</h1>', 200); Response::make('<h1>Hello</h1>', 200, ['Content-Type' => 'text/html']); // JSON response Response::json(['key' => 'value']); Response::json(['error' => 'Not found'], 404); // No content Response::noContent(); Response::noContent(205); // File download Response::download('/path/to/file.pdf', 'download.pdf'); // Inline file (display in browser) Response::file('/path/to/file.pdf'); // Redirect (uses wp_redirect() when available) Response::redirect('/new-path'); Response::redirect('/new-path', 301);
All methods support chaining for headers:
Response::make(view('styles.index'), 200) ->header('Content-Type', 'text/css') ->header('Cache-Control', 'public, max-age=3600');
URL Generation
Sloth provides a UrlGenerator that abstracts WordPress URL functions and integrates with the router for named route URLs. All values are read from the container — WordPress functions are never called directly in your code.
Via Facade
use Sloth\Facades\URL; URL::home() // https://example.com URL::to('/about') // https://example.com/about URL::theme() // https://example.com/wp-content/themes/my-theme URL::theme('css/app.css') // https://example.com/.../my-theme/css/app.css URL::asset('css/app.css') // https://example.com/.../my-theme/public/css/app.css URL::content() // https://example.com/wp-content URL::uploads() // https://example.com/wp-content/uploads URL::route('post.show', ['slug' => 'hello']) // https://example.com/posts/hello URL::current() // /current/path URL::full() // https://example.com/current/path
Via Helper
url('/about') // https://example.com/about url()->theme('css/app') // https://example.com/.../theme/css/app.css url()->asset('js/app.js') // https://example.com/.../theme/public/js/app.js url()->route('post.show', ['slug' => 'hello'])
In Twig
{{ url('/about') }}
{{ url().theme('css/app.css') }}
{{ url().asset('js/app.js') }}
{{ url().route('post.show', { slug: 'hello' }) }}
Configuration
Database
Sloth reads database configuration from database config key. WordPress constants (DB_HOST, DB_NAME etc.) are used
as fallbacks. Override in app/config/database.php:
<?php return [ 'default' => 'wordpress', 'connections' => [ 'wordpress' => [ 'driver' => 'mysql', 'host' => env('DB_HOST', DB_HOST), 'database' => env('DB_NAME', DB_NAME), 'username' => env('DB_USER', DB_USER), 'password' => env('DB_PASSWORD', DB_PASSWORD), 'prefix' => env('DB_PREFIX', DB_PREFIX), 'charset' => 'utf8mb4', 'collation' => 'utf8mb4_unicode_ci', ], // Add extra connections 'external' => [ 'driver' => 'mysql', 'host' => env('EXTERNAL_DB_HOST'), 'database' => env('EXTERNAL_DB_NAME'), 'username' => env('EXTERNAL_DB_USER'), 'password' => env('EXTERNAL_DB_PASSWORD'), 'prefix' => '', 'charset' => 'utf8mb4', 'collation'=> 'utf8mb4_unicode_ci', ], ], ];
Use a specific connection in a model:
class ExternalModel extends Model { protected $connection = 'external'; }
Environment Variables
APP_ENV=local APP_DEBUG=true DB_HOST=localhost DB_NAME=wordpress DB_USER=root DB_PASSWORD= DB_PREFIX=wp_
Custom Configuration
Add files to app/config/ — each filename becomes a config key:
// app/config/theme.php return [ 'colors' => ['primary' => '#ff0000'], ]; // Access via config('theme.colors.primary');
Auto-Discovery: Convention over Configuration
Sloth automatically discovers and registers classes in convention-based directories.
app/ vs theme/
| Path | Scope | When to use |
|---|---|---|
app/ |
Always loaded, regardless of active theme | Shared models, APIs, reusable components |
theme/ |
Only loaded for the active theme | UI, presentation logic, theme-specific hooks |
Recommended Structure
| Component | Location | Why |
|---|---|---|
| Models | app/Model/ |
Data structure — theme-independent |
| Taxonomies | app/Taxonomy/ |
Data structure — belongs with models |
| Routes | app/routes/web.php or theme/routes/web.php |
App-wide or theme-specific routes |
| Modules | theme/Module/ |
UI components — always theme-specific |
| API Controllers | Both | General APIs in app/, theme-specific in theme/ |
| Providers | Both | Framework services in app/, theme hooks in theme/ |
| Includes | Both | Shared helpers in app/, theme functions in theme/ |
Models (Custom Post Types)
<?php // app/Model/NewsModel.php namespace App\Model; use Sloth\Model\Model; class NewsModel extends Model { protected $postType = 'news'; protected $options = [ 'public' => true, 'show_in_rest' => true, 'menu_icon' => 'dashicons-admin-post', ]; protected $names = [ 'singular' => 'News', 'plural' => 'News', 'slug' => 'news', ]; }
What happens automatically:
- Post type
newsis registered viaregister_extended_post_type() - Model is available for Eloquent queries (
NewsModel::all())
To disable registration:
protected $register = false;
Taxonomies
<?php // app/Taxonomy/CategoryTaxonomy.php namespace App\Taxonomy; use Sloth\Model\Taxonomy; class CategoryTaxonomy extends Taxonomy { protected $slug = 'category'; protected $postTypes = ['news', 'post']; protected $unique = false; protected $names = [ 'singular' => 'Category', 'plural' => 'Categories', ]; }
Modules
<?php // theme/Module/TeaserModule.php namespace Theme\Module; use Sloth\Module\Module; class TeaserModule extends Module { public $json = ['params' => ['id']]; }
What happens automatically:
- Available in Twig:
{% include 'module/teaser' %} - REST endpoint:
GET /sloth/v1/module/teaser[/{id}]
Service Providers
<?php // theme/Providers/ThemeProvider.php namespace Theme\Providers; use Sloth\Core\ServiceProvider; class ThemeProvider extends ServiceProvider { public function register(): void { $this->app->singleton('my-service', fn() => new MyService()); } public function boot(): void { // Boot-time setup } public function getHooks(): array { return [ 'init' => fn() => $this->doSomething(), 'admin_menu' => ['callback' => fn() => $this->addMenu(), 'priority' => 20], ]; } public function getFilters(): array { return [ 'the_content' => fn(string $c) => $this->transform($c), ]; } }
API Controllers
<?php // app/Api/NewsController.php namespace App\Api; use Sloth\Api\Controller; class NewsController extends Controller { public function index() { return NewsModel::published()->get(); } public function single($id) { return NewsModel::find($id); } }
What happens automatically:
index()→GET /wp-json/sloth/v1/newssingle()→GET /wp-json/sloth/v1/news/{id}
Includes
Any .php file in app/Includes/ or theme/Includes/ is automatically require_once'd during boot.
Service Provider Hooks
Registering Actions
public function getHooks(): array { return [ // Single callback (priority 10) 'init' => fn() => $this->registerPostTypes(), // With explicit priority 'admin_menu' => ['callback' => fn() => $this->addMenu(), 'priority' => 20], // Multiple callbacks 'wp_loaded' => [ ['callback' => fn() => $this->early(), 'priority' => 5], ['callback' => fn() => $this->late(), 'priority' => 20], ], ]; }
Registering Filters
public function getFilters(): array { return [ 'the_title' => fn(string $title) => '★ ' . $title, 'body_class' => [ 'callback' => fn(array $classes) => [...$classes, 'my-class'], 'priority' => 20, ], ]; }
When to Use EventBridge Instead
For shared hooks that multiple components might listen to, use the EventBridge in boot():
use Sloth\Event\WpHookFired; use Illuminate\Support\Facades\Event; public function boot(): void { Event::listen('wp:the_content', function (WpHookFired $event) { $event->result = transform($event->result); }); }
WordPress Event Bridge
Sloth bridges WordPress hooks to the Laravel event system.
Listening to Actions
Event::listen('wp:wp_loaded', function (WpHookFired $event) { // WordPress fully loaded });
Modifying Filters
Event::listen('wp:body_class', function (WpHookFired $event) { $event->result = [...(array) $event->result, 'my-class']; });
Available Hooks
| Hook | Type | Phase |
|---|---|---|
muplugins_loaded |
action | MU-plugins loaded |
plugins_loaded |
action | All plugins loaded |
after_setup_theme |
action | Theme loaded |
init |
action | WordPress initialized |
wp_loaded |
action | Full WordPress setup |
template_redirect |
action | Before template loading |
wp_head |
action | Inside <head> |
wp_footer |
action | Before </body> |
the_content |
filter | Post content |
the_title |
filter | Post title |
the_excerpt |
filter | Post excerpt |
body_class |
filter | Body classes |
shutdown |
action | PHP shutdown |
Dynamic Hook Registration
$bridge = app(WordPressEventBridge::class); $bridge->addHook('save_post', 'action'); Event::listen('wp:save_post', function (WpHookFired $event) { $postId = $event->firstArg(); });
Directory Structure
sloth/
├── src/
│ ├── ACF/ # ACF integration
│ ├── Admin/ # WordPress admin
│ ├── Api/ # REST API controllers
│ ├── Cache/ # Cache layer
│ ├── Configure/ # Legacy config compat
│ ├── Console/ # WP-CLI commands
│ ├── Context/ # View context
│ ├── Core/ # Container, Application, ServiceProvider
│ ├── Database/ # Eloquent/Capsule setup
│ ├── Debug/ # DebugBar integration
│ ├── Event/ # WordPress EventBridge
│ ├── Exceptions/ # Exception handling
│ ├── Facades/ # Facade classes
│ ├── Filesystem/ # File system helpers
│ ├── Http/ # Response, HttpServiceProvider
│ ├── LayotterBridge/ # Layotter page builder integration
│ ├── Media/ # Media handling
│ ├── Model/ # Eloquent models + registrars
│ ├── Module/ # Module system
│ ├── Pagination/ # Pagination
│ ├── Routing/ # Route, Router, RoutingServiceProvider
│ ├── Support/ # Manifests, utilities
│ ├── Template/ # WordPress template hierarchy
│ ├── Theme/ # Theme bootstrapping
│ ├── Translation/ # i18n
│ ├── Validation/ # Form validation
│ └── View/ # Twig view rendering
├── tests/ # Pest test suite
├── composer.json
└── phpunit.xml
Available Facades
| Facade | Description |
|---|---|
Route |
Custom routing (Route::get/post/put/delete) |
Response |
HTTP responses (Response::make/json/redirect) |
View |
Twig template rendering |
Cache |
Laravel cache |
Validation |
Form validation |
Configure |
Legacy config access (deprecated — use config()) |
File |
Filesystem operations |
URL |
URL generation (URL::home/theme/asset/route) |
WP-CLI Commands
# List commands wp sloth list # Welcome message wp sloth inspire # Clear manifest cache wp sloth manifest:clear # Get a config value wp sloth config:get app.env
Creating Custom Commands
<?php // app/Console/Commands/MyCommand.php namespace App\Console\Commands; use Sloth\Console\Command; use function Termwind\render; class MyCommand extends Command { protected $signature = 'my:command {name?}'; protected $description = 'My custom command'; public function handle(): int { render('<div class="text-green-500">Hello ' . $this->argument('name') . '!</div>'); return self::SUCCESS; } }
Commands are auto-discovered from src/Console/Commands/, app/Console/Commands/, and theme/Console/Commands/.
Development
# Run tests composer test # Static analysis composer analyse # Code style check composer cs-check # Code style fix composer cs-fix
Contributing
Contributions are welcome! Please see CONTRIBUTING.md for details.
License
MIT — see LICENSE.