roots/acorn
Acorn brings Laravel to WordPress: Blade templates, routing, caching, events, validation, and Laravel packages in your WordPress projects, with Laravel's patterns for cleaner, better code.
Prerequisites and install
composer require roots/acorn and boot it from the theme's functions.php.wp acorn about # version, Laravel version, environment
wp acorn list # every command Acorn adds to WP-CLI
If wp acorn doesn't exist, Acorn isn't booting — that's your first bug, not your Blade template.
Where to look first, in this order
functions.php — the bootloader entry point. Everything else depends on it running.app/Providers/ThemeServiceProvider.php — where you'll register view composers, bindings, hooks, and Blade directives. This is your daily driver file.config/*.php — Laravel-style config (app.php, view.php, assets.php, database.php). Publish defaults with wp acorn vendor:publish if a config file is missing.routes/web.php — Acorn's route file. Even if you never add a route, knowing it exists explains a lot of "why is WordPress not handling this URL" behaviour.resources/views/ — Blade templates that map onto the WordPress template hierarchy (index, single, archive, page-{slug}, 404), plus layouts/app.blade.php.First use case: turn one PHP template into Blade
Rename single.php → resources/views/single.blade.php and keep the WordPress loop intact:
{{-- resources/views/single.blade.php --}}
@extends('layouts.app')
@section('content')
@while(have_posts()) @php(the_post())
<article @php(post_class())>
<h1>{{ get_the_title() }}</h1>
{!! get_the_content() !!}
</article>
@endwhile
@endsection
That single change is the assessment: you now have layout inheritance, escaping by default, and components — while every get_the_* function still works. Do not migrate the whole theme at once; migrate templates you're actively touching.
Second use case: a page WordPress shouldn't own
// routes/web.php
use Illuminate\Support\Facades\Route;
Route::get('/quote-calculator', fn () => view('pages.calculator'))->name('quote');
Verify with wp acorn route:list, then hit the URL. Acorn short-circuits parse_request so WordPress doesn't 404 on it.
Blade for everything WordPress renders
single-{post_type} and friends, and (since v6.3) resolves views living outside the theme directory — which is what makes package/plugin-provided views work.Roots\view() inside render_callback for blocks and shortcodes instead of include:register_block_type('theme/hero', [
'render_callback' => fn ($attributes) => view('blocks.hero', [
'heading' => $attributes['heading'] ?? '',
])->render(),
]);
<x-button variant="primary">) for design-system primitives. Use @props + $attributes so components stay WP-agnostic.View composers as your data layer (not functions.php)
Put query logic in a composer, not in the template. Since v4, public methods and properties on a composer are automatically exposed to the view:
namespace App\View\Composers;
use Roots\Acorn\View\Composer;
class Archive extends Composer
{
protected static $views = ['archive', 'index'];
public function posts(): array
{
return get_posts(['numberposts' => 6, 'post_status' => 'publish']);
}
}
// ThemeServiceProvider::boot()
View::composer(['archive', 'index'], \App\View\Composers\Archive::class);
Register composers in a provider, not by calling View::composer() at file scope — the provider is loaded once and is cache-friendly.
Routing + controllers for actual application endpoints
Use Laravel routes for form posts, JSON endpoints, and authenticated flows; leave content URLs to WordPress.
Route::middleware('web')->group(function () {
Route::post('/contact', ContactController::class)->name('contact');
});
Route::get('/api/products', [ProductController::class, 'index']);
$request->validate([...]) and redirect back with ->withInput()->withErrors(). Errors land in the session, so the web middleware group matters.admin-ajax.php. Acorn deliberately bails out of bootstrapping on wp-admin, wp-login.php, and other /wp- requests, so admin-ajax is not a friendly place for framework code. Use the REST API or a named route.Assets: use the Bundle API, not raw wp_enqueue_script
bundle('app')->enqueue(); // CSS + JS from the manifest
bundle('app')->enqueueJs(async: true); // v4+ supports async/defer
In Blade, use the Vite directive and CSP nonce helpers:
@vite(['resources/js/app.js', 'resources/css/app.css'])
If you call Vite::useCspNonce() / useIntegrityKey() / prefetch(), upgrade to ≥ 6.1 — earlier versions dropped that state because the Vite singleton wasn't shared across resolution keys.
Bridging WordPress hooks into Laravel idioms
add_action('save_post', fn ($id) => Event::dispatch(new PostSaved($id)));
new-ing them: app(Indexer::class)->index($id).boot() cheap. It runs on every front-end request. Wrap expensive registration in is_admin() or wp_doing_ajax() guards.Console, database, and deploy
illuminate/database with a config/database.php connection and wp acorn migrate. This is excellent for plugin-owned tables — and a trap for WordPress core tables (see Gotchas).wp acorn queue:work --stop-when-empty from cron/systemd, or use the sync driver and accept inline execution.wp acorn optimize:clear (or at minimum view:clear) and, if you're routing heavily, wp acorn route:cache. storage:link / storage:unlink are registered as wp acorn commands as of v6.2 — use them for uploads outside wp-content/uploads if you need public disk URLs.Livewire for interactive UI
Acorn v4+ ships Livewire support. For a searchable archive, filtered listing, or multi-step form, a Livewire component beats hand-rolled fetch + admin-ajax. Scaffold, register the component, and render it in Blade:
<livewire:product-search />
Keep Livewire components out of the WP loop; pass IDs in, query inside the component.
Extending the framework
Blade::directive('svg', fn ($expr) => "<?php echo ... ?>") in a provider.Composer::with()/override() behaviour was buggy in 4.0.2 and earlier (double-triggering), so pin ≥ 4.0.3 if you subclass those.The unsupported-components list is a hard boundary
illuminate/mail, notifications, broadcasting, redis, and translation are not supported. Concretely:
Mail::send() and no Notifiable trait. Use wp_mail(), or an SMTP plugin, or a transactional-email API through the HTTP client (guzzlehttp/guzzle was added specifically to make Http:: work).__()/trans() from Laravel. Use WordPress's __(), _e(), esc_html__() and text domains. Mixing the two silently returns the input string, which is a nasty "translation just doesn't work" bug.Redis facade. If you need Redis, use the WP Redis object-cache drop-in and a community Acorn cache package; Cache::store('redis') will not work.Don't point illuminate/database at WordPress core tables
It will appear to work, and then bite you: $wpdb's table prefix, the object cache, and wp_posts semantics (revisions, autosaves, post_status quirks) aren't modelled by Eloquent. Use Eloquent for your own tables only. If you must read core data, use $wpdb or WP_Query and shape the result in a composer or repository.
Request handling is deliberately conservative
wp-login.php, wp-admin, and other /wp- paths. Don't put Blade or container-dependent logic in code that only runs there.ACORN_ENABLE_EXPERIMENTAL_WORDPRESS_REQUEST_HANDLER=true. Do not build middleware that expects $response on normal WordPress page loads; middleware is for your custom routes only.Route caching
route:cache is a genuine deploy-time win, but stale caches after renaming or removing a route produce baffling 404s. Always pair it with route:clear in local dev, and never commit the cache.
Blade compile cache and permissions
Compiled views live under storage/framework/views (or your configured view.compiled). Symptom of a permissions problem: templates silently render stale or throw on write, only on the production server. Fix by setting ownership correctly, then wp acorn view:clear. Also clear views when you rename a template — the hierarchy resolver caches aggressively.
functions.php scope creep
Every add_action in functions.php bypasses the container and can't be unit tested. Treat it as a bootstrap file: require the autoloader, boot Acorn, and move on. Everything else belongs in app/.
Debugging toolkit
wp acorn about first, always — environment, Laravel version, config source.APP_DEBUG=true gets you the Ignition error page with solutions; Acorn registers Manifest and SkipProvider solutions, which is far better than a blank white screen when a Vite manifest is missing.dump() / dd() are available (symfony/var-dumper is a dependency, and v6.2 widened the constraint to ^8.0).Log::info() writes to storage/logs/laravel.log; error_log() / WP_DEBUG_LOG still write to the WP debug log. Know which one you're reading.wp acorn route:list before you debug a 404; wp acorn config:show <key> when a config value "isn't taking".Container vs. globals — the mental model
Acorn does not wrap $post, $wp_query, or $current_user in the container. Inside Blade and composers, use get_post(), wp_get_current_user(), get_queried_object(). Reserve injection for your own services. Mixing the two produces "works in the controller, undefined in the view" bugs.
Version-pin deliberately
Composer::with()/override() double-triggering was fixed in 4.0.3; anything older will run your composer setup twice per render.Escape by default, and know when not to
{{ }} escapes; {!! !!} doesn't. get_the_content() needs the latter (or apply_filters('the_content', ...)) because it's already-sanitised HTML. For ACF/WYSIWYG fields, run wp_kses_post() before echoing. The most common Acorn security bug is a developer treating {!! !!} as "how you output content."
Where to extend rather than patch
Service providers (app/Providers), published config, view composers, Blade directives, custom wp acorn commands, and the Bundle API for assets. If you find yourself editing something inside vendor/roots/acorn, you almost certainly want a filter, a config override, or a child provider — this package moves fast (six minor releases in the v6 line), and forks rot.
How can I help you explore Laravel packages today?