About blade-conventions
blade-conventions is a coding skill for creating and editing Bagisto Blade views. It documents component usage, Blade and Vue binding rules, package namespaces, and formatting conventions so new templates match the surrounding codebase.
Key features
- 01Document Blade component patterns
- 02Explain Blade and Vue bindings
- 03Cover package namespaces and translations
- 04Describe forms and extension hooks
- 05Specify template formatting rules
Use cases
- 01Create an Admin or Shop view
- 02Build reusable Blade components
- 03Wire Vue-backed components and forms
- 04Match formatting in a custom module
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ bagisto/ agent-skills --skill blade-conventionsAfter installing, type/blade-conventionsin Claude Code to confirm it loaded.
Details
From the project READMEBagisto Blade Conventions
Overview
Bagisto UIs are Blade templates that lean heavily on Blade components and an inline Vue 3 layer. New views must be indistinguishable from the surrounding code: same component idioms, the same : vs :: binding rules, and the same indentation/attribute formatting. This skill captures those conventions so generated Blade matches the codebase exactly.
These conventions are the same in every package — Admin, Shop, and any custom Webkul-style module. What changes between packages is only a set of namespace tokens (the x-<ns>:: component prefix, the <ns>:: translation namespace, and the view_render_event names); see "Per-package namespaces" below. When in doubt, open a nearby view in the same package and mirror it.
Core UIs live under packages/Webkul/Admin/src/Resources/views/ and packages/Webkul/Shop/src/Resources/views/; other packages (including your own) follow the same directory shape under their own src/Resources/views/. Each package that ships components has a components/example.blade.php that demonstrates usage — treat it as the canonical reference.
Per-package namespaces (Admin, Shop, custom modules)
A package's tokens come from two calls in its ServiceProvider::boot():
// Registers the `<ns>::` view + translation namespace → view('<ns>::…'), @lang('<ns>::…')
$this->loadViewsFrom(__DIR__.'/../Resources/views', '<ns>');
// Registers the `x-<ns>::` anonymous component prefix → <x-<ns>::button />
Blade::anonymousComponentPath(__DIR__.'/../Resources/views/components', '<ns>');
So Admin registers admin (→ x-admin::, @lang('admin::…')), Shop registers shop, and a package of your own — say RMA — registers rma (→ x-rma::, @lang('rma::…')).
What this means when working outside Admin/Shop:
- Reuse the shared components freely.
x-admin::andx-shop::components/layouts are registered globally, so an RMA admin page still wraps in<x-admin::layouts>and uses<x-admin::datagrid>,<x-admin::form.control-group.*>,<x-admin::modal>, etc. A customer-facing page uses<x-shop::layouts>and shop components. You do not re-implement these. - Prefix only your own new components with your package namespace:
<x-rma::return-request-card>. - Translations use your namespace:
@lang('rma::app.return-requests.index.title'), with keys underpackages/Webkul/RMA/src/Resources/lang/. view_render_eventnames follow the package/area, e.g.bagisto.rma.return-requests.list.before(mirror the naming of nearby events in that package).- Everything else is unchanged: the
:/::binding rules, the Vue<v-x>+ x-template recipe,@props, formatting, ACL, and script stacking all apply verbatim.
Quick substitution map when moving between packages:
| Token | Admin | Shop | Your package (example: RMA) |
|---|---|---|---|
| Own component prefix | x-admin:: | x-shop:: | x-rma:: |
| Translation namespace | admin::app.… | shop::app.… | rma::app.… |
| Layout to wrap in | x-admin::layouts | x-shop::layouts | reuse x-admin::/x-shop:: layouts |
| Event prefix | bagisto.admin.… | bagisto.shop.… | bagisto.rma.… |
When to Apply
Activate when:
- Creating or editing any
.blade.phpunder the Admin or Shop packages - Building a reusable Blade component (anonymous or Vue-backed)
- Wiring forms, datagrids, modals, drawers, tabs, or layouts
- Matching the project's attribute-binding, indentation, and blank-line style
Directory & Component Structure
Admin and Shop mirror each other. Pages sit under a feature folder (catalog/, sales/, checkout/, customers/, …); shared components sit under components/. Common components in both: accordion, button, datagrid, drawer, dropdown, form, layouts, media, modal, quantity-changer, shimmer, table, tabs, tinymce.
Namespaced invocation: <x-admin::name> / <x-shop::name>, nested with dots: <x-admin::form.control-group.control>, <x-admin::charts.bar>.
Component Invocation & Data Binding (most important rule)
Three distinct attribute forms — pick deliberately:
| Syntax | Resolves as | Use for | Example |
|---|---|---|---|
attr="text" | static string | literals | name="quantity" |
:attr="expr" | Blade/PHP expression | PHP values, routes, trans(), old() | :src="route('admin.sales.orders.index')" |
::attr="expr" | escaped : → literal :attr for Vue | data passed into the Vue component | ::value="item?.quantity", ::labels="chartLabels" |
The :: (double colon) is Blade escaping a single : so the rendered HTML contains :attr="expr" for Vue to bind at runtime. Getting : vs :: right is the single most common source of bugs.
:: only works on a Blade component tag — <x-admin::…>, <x-shop::…>, <x-marketplace::…>. Blade unescapes it while compiling the component. A plain custom element (<v-quantity-changer>, <v-field>, any <v-…> you wrote yourself) is passed through as literal HTML, so ::attr reaches the browser with both colons, Vue does not recognise it as a binding, and the prop silently never arrives. On a plain <v-…> element write a single colon:
<x-admin::quantity-changer ::value="item.qty" /> {{-- component → renders :value --}}
<v-quantity-changer :value="item.qty"></v-quantity-changer> {{-- plain element → write : --}}
The failure is silent and easy to misread: the prop is undefined, so any computed that walks it throws during render and the component freezes on whatever it last drew — typically its loading shimmer. If a <v-…> component renders its placeholder forever, check the colons first.
Named slots use <x-slot:name> … </x-slot>:
<x-admin::drawer>
<x-slot:toggle>Toggle</x-slot>
<x-slot:content>Body</x-slot>
</x-admin::drawer>
The Two Component Types
1. Anonymous Blade component (@props + $attributes)
@props([
'isActive' => false,
'position' => 'right',
])
<div {{ $attributes->merge(['class' => 'box-shadow rounded bg-white dark:bg-gray-900']) }}>
{{ $slot }}
</div>
- Declare inputs with
@props([...]). - Forward extra attributes with
$attributes->merge([...]). - Consume default slot with
{{ $slot }}, named slots with{{ $toggle }}etc.
2. Vue-backed component (dominant pattern)
A thin custom-element wrapper + an inline x-template + registration on the global app:
@props([
'name' => '',
'value' => 1,
])
<v-quantity-changer
{{ $attributes->merge(['class' => 'flex items-center']) }}
name="{{ $name }}"
value="{{ $value }}"
>
</v-quantity-changer>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-quantity-changer-template"
>
<div>
<span
class="icon-minus cursor-pointer"
role="button"
@click="decrease"
></span>
<p>@{{ quantity }}</p>
</div>
</script>
<script type="module">
app.component("v-quantity-changer", {
template: '#v-quantity-changer-template',
props: ['name', 'value'],
data() {
return {
quantity: this.value,
};
},
methods: {
decrease() {
this.$emit('change', --this.quantity);
},
},
});
</script>
@endPushOnce
Rules for this pattern:
- Wrapper element is
<v-name>; template id is#v-name-template. - Register with
app.component("v-name", { template: '#v-name-template', ... }). - Wrap scripts in
@pushOnce('scripts')…@endPushOnce(the layout renders@stack('scripts'), so the block emits once no matter how many times the component is used). - Emit literal Vue mustaches as
@{{ expr }}so Blade does not try to render them. - Pass data in via
::attr(Vue binding) orattr="{{ $php }}"(server value).
Page Skeleton
<x-admin::layouts>
<x-slot:title>
@lang('admin::app.catalog.attributes.index.title')
</x-slot>
<div class="flex items-center justify-between">
<p class="text-xl font-bold text-gray-800 dark:text-white">
@lang('admin::app.catalog.attributes.index.title')
</p>
@if (bouncer()->hasPermission('catalog.attributes.create'))
<a href="{{ route('admin.catalog.attributes.create') }}">
<div class="primary-button">
@lang('admin::app.catalog.attributes.index.create-btn')
</div>
</a>
@endif
</div>
{!! view_render_event('bagisto.admin.catalog.attributes.list.before') !!}
<x-admin::datagrid :src="route('admin.catalog.attributes.index')" />
{!! view_render_event('bagisto.admin.catalog.attributes.list.after') !!}
</x-admin::layouts>
Shop pages use <x-shop::layouts> and may add @push('meta') for SEO tags and @inject('helper', '...') for view helpers.
Cross-Cutting Idioms
- Extensibility hooks — bracket meaningful content with
{!! view_render_event('bagisto.<area>.<path>.before') !!}and.after. The dotted name follows the view path. Present in the large majority of pages. - Translations — never hardcode UI strings. Use
@lang('admin::app.…')/@lang('shop::app.…')ortrans('…'), always package-namespaced. When adding keys, add them to all locales underResources/lang/. - ACL (admin only) — gate create/edit/delete buttons and datagrid actions with
@if (bouncer()->hasPermission('resource.action')). - Forms —
<x-admin::form :action="route(...)" method="POST">wraps a VeeValidatev-form. Build fields with the control-group trio:
Validation is client-side;<x-admin::form.control-group> <x-admin::form.control-group.label class="required"> @lang('...') </x-admin::form.control-group.label> <x-admin::form.control-group.control type="text" name="admin_name" rules="required" :value="old('admin_name')" :label="trans('...')" /> <x-admin::form.control-group.error control-name="admin_name" /> </x-admin::form.control-group>control-nameon the error component must match the fieldname. - DataGrids —
<x-admin::datagrid :src="route('…')" />. Columns, filters, and actions are defined in a PHPDataGridclass; the Blade tag only points at the JSON endpoint. - Blade ↔ Vue escaping —
@{{ vueVar }}prints a literal Vue mustache; wrap a block inv-preto keep Blade from touching@/{{ }}inside it. - Theming / i18n shell — the layout sets
class="… dark …"anddir="{{ core()->getCurrentLocale()->direction }}"; use Tailwinddark:andltr:/rtl:variants for theme/RTL awareness.
Formatting & Style Rules
These are enforced by hand in .blade.php (Pint does not format Blade), so match them precisely.
Indentation
- 4 spaces, no tabs. One clean 4-space step per nesting level.
Attribute layout
- More than one attribute → one per line, each indented +4 from the tag, with the closing
>(or/>) on its own line aligned with the opening<tag:<x-admin::form.control-group.control type="text" name="admin_name" :value="old('admin_name')" /> - Single attribute → inline:
<x-admin::form.control-group.error control-name="admin_name" />. - Attribute lines are contiguous — no blank lines between attributes inside one tag.
- Loose attribute order: control-flow/Vue directives (
v-if,v-else) →class/:class→ plain &aria-*attributes → event handlers (@click,@change) last.
@props — align the =>
@props([
'name' => '',
'value' => 1,
'minValue' => 1,
'removable' => false,
])
Pad keys so the => arrows line up. A @props block is a declaration the reader scans as a table, so it is the one place alignment earns its keep.
PHP inside @php … @endphp — Pint's rules, applied by hand
Inside an @php block you are writing plain PHP, so it follows exactly what vendor/bin/pint
would produce for a .php file — most visibly, a single space either side of =>, never padded
to align:
@php
$steps = [
'label' => trans($prefix.'.source'),
'hint' => trans($prefix.'.source-hint'),
'validate' => ['file'],
];
@endphp
Pint does not format .blade.php files, so nothing enforces this — you have to write it that
way yourself. When unsure how a construct should look, write it in a scratch .php file, run Pint
on it, and copy the result. The same applies to the rest of Pint's output: spacing, operators,
trailing commas, and short closures.
Everything outside @php — @props, directive arguments, inline {{ }} expressions — is Blade's
own layer and keeps the Blade rules on this page.
Blank lines
- One blank line between sibling blocks/elements and around
view_render_eventhooks. - One blank line after the
@props([...])block before the markup.
Directive casing / naming
- Standard push block:
@pushOnce('scripts')…@endPushOnce(note the capitalPin@endPushOnce).'styles'is used rarely for CSS.
Comments
Two rules from the package-development skill govern Blade too, and they pull in opposite directions — apply both:
- "Every method gets a docblock" — a Vue
methodorcomputedin a<script>block is a method. It gets a/** … */describing what it does, as a capitalised sentence ending in a full stop. Same for any function inside an@phpblock. - "Comment only what the code cannot say" — inside a method body, and in the markup, the default is no comment. Explain a constraint that is invisible locally, a workaround, or a line that looks removable but is load-bearing; skip anything that merely restates the markup or the call.
What follows is the syntax to use for each layer.
Each comment syntax belongs to its own layer — pick by where the comment sits, then follow the casing/punctuation rule for that layer.
-
Blade
{{-- --}}— for Blade/PHP-level notes (file headers,@phplogic, control-flow explanations). Never ships to the browser.- If the comment is a sentence (or sentences), write it as prose with a capital first letter and terminal punctuation. Multi-line headers explain what the view is and why:
{{-- The friendly "What kind of product?" create funnel (stepped scratch page only). It maps a plain kind + a "Does it have variations?" answer to a Bagisto product type behind the scenes. --}} - If it is a short label/title (not a sentence), use Title Case with no trailing period:
{{-- Media --}},{{-- Type-Specific Controls --}}. - Not inside
@php … @endphp. Blade does not strip its own comment there — it copies it verbatim into the compiled PHP, producing a parse error. Inside an@phpblock you are writing plain PHP, so use PHP comment syntax and the PHP conventions that go with it; put a{{-- --}}header above the block if the whole block needs explaining:{{-- Everything the wizard needs to describe its steps. --}} @php // The upload-size config is stored in MB; VeeValidate's size rule expects KB. $maxUploadKb = $maxUploadMb * 1024; @endphp
- If the comment is a sentence (or sentences), write it as prose with a capital first letter and terminal punctuation. Multi-line headers explain what the view is and why:
-
HTML
<!-- -->— section dividers inside an x-template / markup. Keep them short: a Title-Cased label or a natural question — mirror the heading the block renders. No trailing period on labels; a?on a question is fine:<!-- What kind of product? --> <!-- Attribute Family (Searchable) --> <!-- SKU --> -
JS
/** … */(JSDoc block) — inside<script>. This is the only comment form used in the Vue layer — no//line comments. Everycomputed/methodgets one, as a capitalized sentence ending in a full stop. Statements inside a method get one only when genuinely non-obvious:/** * The axis checkboxes only appear for a variable kind, once the seller has said "yes" * and picked a family. */ showAxes() { return this.isVariable && this.hasVariations === true && !! this.familyId; }A trivial one-liner still gets its docblock — but keep it to the one sentence, and don't pile extra commentary onto statements that already read clearly.
-
CSS
/** … */(JSDoc block) — inside<style>, same form as JS: describe why a rule exists (especially non-obvious ones like:checked-driven state or scrollbar hiding), as a punctuated sentence:/** * Hide the navigation radios; they only drive the :checked state. */ #rma-steps .rma-step-radio { … }
Rule of thumb: sentence → capitalized + punctuated; bare title/label → Title Case, no period. In <script>/<style> always use the /** … */ block form, never // or bare /* */.
Recipe: New Vue-backed component
- Create
views/components/<name>/index.blade.php. @props([...])for the server-side inputs (aligned=>).- Render
<v-<name> {{ $attributes->merge([...]) }} …>passing data viaattr="{{ $php }}". @pushOnce('scripts'): an<script type="text/x-template" id="v-<name>-template">and<script type="module"> app.component("v-<name>", { template: '#v-<name>-template', props, data, computed, methods }) </script>; close with@endPushOnce.- Emit runtime values as
@{{ … }}; bind parent data with::attr. - Use it as
<x-{admin|shop}::<name> ::value="…" @change="…" />.
Recipe: New page
- Wrap in
<x-admin::layouts>/<x-shop::layouts>with an<x-slot:title>. - Add a header row (title + permission-gated action buttons via
bouncer()). - Bracket the main content with
view_render_event.before/.after. - Put every string through
@lang/transwith the package namespace.
Do / Don't
- Do reuse existing
<x-{admin|shop}::…>components and slots; don't hand-roll markup a component already provides. - Do pick
::for anything the Vue layer consumes and:for PHP values. - Do align
=>in a@propsblock; don't align it inside@php … @endphpor in a.phpfile — that is plain PHP, so Pint's single-space rule applies. - Do wrap component scripts in
@pushOnce('scripts')…@endPushOnce. - Do namespace all strings through
@lang/transand add new keys to every locale. - Don't put blank lines between a tag's attributes; do put a blank line between sibling blocks.
- Do gate admin actions with
bouncer()->hasPermission(...).