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.
- 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
- PHP: 8.4 or higher
- WordPress: 5.0 or higher
- Composer: 2.0 or higher
composer create-project sixmonkey/sloth my-themeActivate the theme in WordPress admin. Sloth automatically bootstraps and registers all service providers.
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.
<?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();<?php
use Sloth\Facades\View;
// Render a Twig view
View::make('partials.header', ['title' => 'Welcome']);<?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();
}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.
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::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')));
});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']);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');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.
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/pathurl('/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']){{ url('/about') }}
{{ url().theme('css/app.css') }}
{{ url().asset('js/app.js') }}
{{ url().route('post.show', { slug: 'hello' }) }}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';
}APP_ENV=local
APP_DEBUG=true
DB_HOST=localhost
DB_NAME=wordpress
DB_USER=root
DB_PASSWORD=
DB_PREFIX=wp_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');Sloth automatically discovers and registers classes in convention-based directories.
| 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 |
| 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/ |
<?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;<?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',
];
}<?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}]
<?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),
];
}
}<?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}
Any .php file in app/Includes/ or theme/Includes/ is automatically require_once'd during boot.
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],
],
];
}public function getFilters(): array
{
return [
'the_title' => fn(string $title) => '★ ' . $title,
'body_class' => [
'callback' => fn(array $classes) => [...$classes, 'my-class'],
'priority' => 20,
],
];
}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);
});
}Sloth bridges WordPress hooks to the Laravel event system.
Event::listen('wp:wp_loaded', function (WpHookFired $event) {
// WordPress fully loaded
});Event::listen('wp:body_class', function (WpHookFired $event) {
$event->result = [...(array) $event->result, 'my-class'];
});| 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 |
$bridge = app(WordPressEventBridge::class);
$bridge->addHook('save_post', 'action');
Event::listen('wp:save_post', function (WpHookFired $event) {
$postId = $event->firstArg();
});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
| 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) |
# 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<?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/.
# Run tests
composer test
# Static analysis
composer analyse
# Code style check
composer cs-check
# Code style fix
composer cs-fixContributions are welcome! Please see CONTRIBUTING.md for details.
MIT — see LICENSE.