Mount chatbox, sidebar group, hay page card — không cần fork một file Blade.

Host application dành ra bốn loại slot để plugin render vào: ba slot ở mức layout fire trên mọi trang extend master app / admin layout, cộng với một slot per-page cho injection mịn hơn. Cả bốn dùng REGISTRY pattern — mọi plugin đã register contribute một HTML string (hoặc null để skip), host iterate qua array, filter các return falsy, và emit từng fragment không escape. Trang này nói về contract, args-bag, implementation chính tắc của acelle/ai, và những anti-pattern trông có vẻ đúng nhưng hỏng trong production.

Vì sao UI injection tồn tại

Plugin add một feature thường cần surface feature đó ở đâu đó trong host UI. Các lựa chọn ngây thơ đều tệ theo cách riêng: fork Blade layout của host buộc plugin phải maintain bản copy riêng qua mỗi lần host upgrade; patch chúng lúc cài đặt khiến source của host out of sync với cái đang chạy production. Cả hai khoá plugin và host vào một gánh nặng bảo trì lớn dần qua mỗi release.

Plugin system tránh trade-off đó bằng cách dành các slot có tên bên trong master layout của host. Mỗi slot là một REGISTRY hook — mọi plugin đã register contribute một HTML string, host gom chúng lại lúc render, filter các contribution falsy, và emit từng fragment theo thứ tự register. Plugin không bao giờ thấy source Blade của host; host không bao giờ biết plugin nào contribute cái gì.

Ba layout slot

Ba REGISTRY hook fire từ master appadmin layout. Cùng nhau chúng cover gần như mọi UI extension mà plugin sẽ cần — head-of-document asset, body-end widget, và admin sidebar group.

Hook keyCall site nằm ở đâuArgs bagDùng cho
layout.head.assets resources/views/refactor/layouts/{app,admin}.blade.php, ngay trước @yield('head') [$layout, $context] Tag <link> / <style> / <script> phải load trước page-specific content — chatbox CSS, sparkle popover script
layout.body.before_close Cùng file, ngay trước </body> [$layout, $context] Floating widget mount một lần mỗi page — chatbox bubble, modal overlay, sparkle popover
admin.sidebar.groups resources/views/refactor/components/nav/admin-sidebar.blade.php (không args) Admin sidebar section plugin contribute — mỗi entry render dưới dạng fragment <div class="mc-nav-group">...</div>

Cả ba được collect qua cùng một idiom ở phía host. Đoạn Blade ship trong resources/views/refactor/layouts/admin.blade.php đọc như sau:

@foreach (array_filter(\App\Library\Facades\Hook::collect('layout.head.assets', ['admin'])) as $html)
    {!! $html !!}
@endforeach

Ba điều cần đọc ra từ snippet đó: collect nhận một args bag (ở đây là ['admin']), array_filter drop mọi contribution null / false / '', và HTML còn lại được emit với {!! !!} — không escape, vì đã là Blade đã render.

Contract — return HTML hoặc null

REGISTRY callback cho bất kỳ slot nào trong ba layout slot return một trong hai thứ:

  1. Một HTML string — thường là kết quả của view('myname::partials.foo')->render(). Host emit nguyên văn với {!! !!}.
  2. null (hoặc bất kỳ giá trị falsy nào — false, '', 0). array_filter của host drop nó. Đây là cách thông thường để gate một contribution theo feature flag, plugin status, environment, hay per-request context.

Return null được ưu tiên hơn việc không register. Plugin boot() chạy một lần mỗi process; quyết định có contribute hay không nên xảy ra trên mỗi render, không phải lúc boot. Check aiPluginAvailable()storage/app/plugins/acelle/ai/src/ServiceProvider.php:732 là ví dụ chính tắc — closure short-circuit về null bất cứ khi nào AI module bị gate off, không đụng đến contribution của plugin khác.

HTML return phải self-contained. Host drop fragment vào document ngay tại call site mà không escape hay wrap thêm. Mọi thứ mà fragment phụ thuộc — CSS, JS, font file — phải đã được load trước khi render xảy ra. Đó là lý do layout.head.assets tồn tại cùng với layout.body.before_close: head fragment load trước, body fragment mount sau, và plugin có thể chia asset registration của mình qua cả hai slot khi cần.

Args-bag — $layout$context

layout.head.assetslayout.body.before_close đều pass hai positional arg: $layout (một string xác định master layout nào fire slot — 'app', 'admin', v.v.) và $context (một mảng optional mang surface-specific prop mà layout chọn expose).

// Plugin (in ServiceProvider::boot())
Hook::add('layout.head.assets', function ($layout = 'app', array $context = []) {
    if (! $this->aiPluginAvailable()) {
        return null;
    }

    return view('ai::partials.head_assets', [
        'layout'  => $layout,
        'context' => $context,
    ])->render();
});

Một hook key duy nhất dùng chung fire từ mọi master layout — app, admin, email builder, form builder, automation editor. Partial của plugin dispatch nội bộ trên $layout để render đúng asset set hoặc chatbox configuration. Không có hook layout.app.head.assets / layout.admin.head.assets riêng; tên layout chỉ là discriminator trong một shared bag.

Thêm positional arg vào một layout slot có sẵn sẽ phá vỡ mọi plugin đã bind closure với arity gốc. Context mới thuộc về mảng $context (có thể grow mà không đổi signature closure), hoặc đứng sau một hook key riêng. Các aiHooks contribution của chính host xử lý đúng như vậy — builder và automation editor pass surface prop qua $context, và plugin đọc $context['kind'], $context['task'], v.v. khi có.

Worked example — acelle/ai chatbox bubble

Reference chính tắc cho layout injection nằm ở storage/app/plugins/acelle/ai/src/ServiceProvider.php, dòng 678-728. Plugin contribute vào cả ba layout slot, mỗi cái đứng sau cùng gate aiPluginAvailable(). Toàn bộ registration block, diễn lại theo contract ở trên:

// In acelle/ai's ServiceProvider::boot()

private function registerLayoutInjections(): void
{
    Hook::add('layout.head.assets', function ($layout = 'app', array $context = []) {
        if (! $this->aiPluginAvailable()) {
            return null;
        }
        return view('ai::partials.head_assets', [
            'layout'  => $layout,
            'context' => $context,
        ])->render();
    });

    Hook::add('layout.body.before_close', function ($layout = 'app', array $context = []) {
        if (! $this->aiPluginAvailable()) {
            return null;
        }
        return view('ai::partials.body_assets', [
            'layout'  => $layout,
            'context' => $context,
        ])->render();
    });
}

private function registerAdminSidebarSection(): void
{
    Hook::add('admin.sidebar.groups', function () {
        if (! $this->aiPluginAvailable()) {
            return null;
        }
        return view('ai::partials.admin_sidebar_group')->render();
    });
}

Cái land vào production từ ba block đó: mọi page extend app hoặc admin layout đều có chatbox CSS / JS trong <head>, chatbox bubble HTML trước </body>, và (chỉ trên admin page) một sidebar group "AI" render sau các group built-in của host. Không file Blade nào của host bị sửa để làm tất cả những điều đó hoạt động — plugin contribute qua các shared hook key và host render bất cứ thứ gì land.

Plugin gate contribution bằng aiPluginAvailable(), hàm này check ai_plugin_active() — một helper cuối cùng resolve về Plugin::getByName('acelle/ai')->isActive(). Khi admin disable plugin từ admin Plugins page, mọi callback return null ở request kế tiếp, và chatbox + sparkle UI biến mất — không cần reload route, drop registered service, hay invalidate cache nào.

admin.sidebar.groups là cái đơn giản nhất trong ba layout hook: không args, callback return một fragment self-contained <div class="mc-nav-group">...</div>. Host render nó sau các group built-in (Customers, Plans, Settings, ...) và trước bất kỳ closing layout nào. Thứ tự là thứ tự register, vậy nên plugin cần thắng render position nên register muộn trong boot() sau dependency.

Sidebar group của acelle/ai nằm ở resources/views/partials/admin_sidebar_group.blade.php bên trong plugin và render một group "AI" với ba hoặc bốn con tuỳ vào plan flag. Cùng pattern hoạt động cho bất kỳ plugin nào cần một top-level admin section — plugin loyalty-points, plugin payment-gateway, plugin sending-driver vùng miền.

Slot mức page — page.{controller}.{action}.{slot}

Injection mức layout cover cái nên xuất hiện trên mọi page; slot mức page cover cái nên xuất hiện trên một page cụ thể. Convention đặt tên làm binding tường minh:

page.{controller_slug}.{action}.{slot}

Ví dụ:

  • page.maillist.show.body — card phụ render bên trong body của trang mail-list detail
  • page.maillist.verification.body — content render phía trên verification status block
  • page.campaign.index.sidebar — sidebar addition trên trang campaign index
  • page.customer.edit.footer — footer addition trên trang customer edit

Call site của host trông giống layout slot — collect, array_filter, emit từng fragment với {!! !!}:


@foreach (array_filter(\App\Library\Facades\Hook::collect('page.maillist.show.body', [$list])) as $html)
    {!! $html !!}
@endforeach

Và contribution của plugin trông y hệt cái cho layout-slot, với slot-specific args bag pass kèm:

// Plugin (in ServiceProvider::boot())
Hook::add('page.maillist.show.body', function ($list) {
    $points = LoyaltyPoints::getTotalForList($list);
    return view('loyalty::list_points_card', [
        'list'   => $list,
        'points' => $points,
    ])->render();
});

Args bag cho slot mức page thường mang theo model liên quan — mail list, campaign, customer — để plugin có thể đọc bất cứ thứ gì cần mà không thêm database query. Theo convention đó giữ hook signature ổn định qua các evolution của host model: thêm field mới vào MailList không phá hook signature của plugin nào, vì plugin luôn nhận model.

Page redirect — biến thể FILTER

Một nắm page hook dùng FILTER pattern thay vì REGISTRY — case điển hình là "để plugin quyết định user có nên bị redirect trước khi core render". Contract:

// Core controller
$redirect = \App\Library\Facades\Hook::filter('page.maillist.show.redirect', null, [$list, $request]);
if ($redirect) {
    return $redirect;
}
// continue rendering the page

// Plugin (in ServiceProvider::boot())
Hook::modify('page.maillist.show.redirect', function ($redirect, $list, $request) {
    if (MyPlugin::shouldRedirect($list, $request->user())) {
        return redirect()->route('my_plugin.custom_page', $list->uid);
    }
    return $redirect; // null means "do not redirect"
});

Shape là FILTER (transform chuỗi của một value) thay vì REGISTRY (nhiều contribution độc lập) vì chỉ một redirect có thể xảy ra mỗi request. Return input không đổi từ closure là cách opt-out thông thường — plugin kế tiếp trong chain thấy bất cứ thứ gì plugin trước đã quyết định. Giá trị non-null đầu tiên thắng vì controller check if ($redirect) sau khi filter chain kết thúc.

Pattern này là cái athena/evs dùng để route user sang trang verification của riêng nó khi plugin email-verification cần take over bề mặt maillist verification. Toàn bộ mechanic của FILTER nằm trong Hook system deep-dive; takeaway thực tế ở đây là page-level redirect dùng FILTER, còn page-level rendering dùng REGISTRY.

Asset publishing — bundle CSS / JS / image cùng plugin

Layout slot nói về chỗ plugin render. Asset publishing nói về cái HTML đã render có thể reference. Plugin ship CSS, JavaScript, font, hay image dùng API publishes() chuẩn của Laravel, với tag 'plugin' mà host biết:

// In ServiceProvider::boot()
$this->publishes([
    __DIR__ . '/../resources/assets' => public_path('plugins/acmecorp/loyalty'),
], 'plugin');

Trong mỗi Plugin::register(), host chạy artisan vendor:publish --tag=plugin --force, lệnh này copy cây resources/assets/ của plugin vào public/plugins/{vendor}/{name}/. Partial của chính plugin reference asset qua path đó:


<link rel="stylesheet" href="{{ asset('plugins/acmecorp/loyalty/styles.css') }}">

Icon-serving route từ routes.php (named route plugin.{vendor}.{name}.icon) là alternative — thay vì publish một SVG tĩnh vào public/, plugin có thể expose một HTTP route stream icon trực tiếp ra ngoài từ storage/app/plugins/{vendor}/{name}/icon.svg. Trade-off: published path nhanh hơn (CDN-cacheable) nhưng đòi publish mỗi lần install; routed path self-contained nhưng trả giá một lần Laravel boot mỗi request.

Anti-pattern

1. Return object Blade View thay vì string

Host emit bất cứ thứ gì closure return với {!! !!}. Return view('foo') emit __toString của object, hoạt động được hầu hết thời gian nhưng mất cơ hội cho closure handle render error một cách graceful. Fix: luôn gọi ->render() và return string kết quả, đúng như các registration của acelle/ai làm.

2. Quên branch null để gate

Một REGISTRY callback luôn return HTML sẽ tiếp tục contribute bất kể plugin active hay không — vì autoloadWithoutDbQuery() load cả plugin inactive (xem Kiến trúc plugin § Vì sao plugin inactive vẫn ảnh hưởng app). Fix: guard bằng Plugin::enabled('myvendor/myplugin') hoặc một helper feature-flag ở đầu mỗi closure, return null khi bị gate off.

3. Gọi collect() với arg phụ mà host không pass

Plugin không tự gọi Hook::collect — host làm. Nếu plugin cần layout name cho một quyết định custom, nó đọc từ arg đầu của closure. Cố Hook::collect một layout slot từ bên trong plugin sẽ chạy callback của mọi plugin khác thêm một lần. Fix: closure nhận tất cả args cần; không bao giờ re-invoke collect từ bên trong một handler đã register.

4. Render công việc blocking bên trong closure

Closure chạy một lần mỗi page render — một Stripe API call hay một DB query 200ms bên trong nó add cost đó vào mỗi request. Fix: precompute, cache, hoặc chuyển sang async loader. Fragment có thể return một placeholder <div data-async-loader> mà JS của plugin hydrate từ một background fetch.

5. Side effect trong REGISTRY callback

collect gọi mọi callback theo thứ tự register. Một callback ghi vào session, cache, hay log như một side effect khiến hook trở nên non-deterministic. Hai plugin có thể race nhau mutate cùng key. Fix: giữ add callback pure — chúng tồn tại để contribute một value, không phải làm việc. Nếu cần side effect, register một EVENT listener trên một hook riêng.

6. CSS scope chồng nhau

Hai plugin cùng inject CSS qua layout.head.assets; cả hai define một class tên .mc-popover. Thứ tự plugin được load là thứ tự CSS của chúng land, và last-wins. Fix: namespace class CSS của plugin (.acmecorp-loyalty-popover), hoặc scope bằng attribute selector trên một wrapper element. Host không kiểm soát CSS của plugin — đó là kỷ luật của plugin author.

Đi tiếp đến đâu

UI injection là bề mặt được hỏi nhiều nhất bởi plugin author mới, nhưng hiếm khi là toàn bộ feature. Ba trang dẫn cùng toolkit này đi xa hơn: