Skip to content

Customize and extend

resources/<ui>/app/ is yours once installed: laravel-setup never touches it again. resources/<ui>/index.js and resources/<ui>/src/ belong to LaraPack and are regenerated. Everything on this page happens in your part or in laraimport.json.

Add a model

Declare it in laraimport.json, next to the user, and generate it as in any LaraPack project:

bash
php artisan larapack:validate laraimport.json --vue
php artisan larapack:import laraimport.json --vue
php artisan migrate
php artisan route:json
npm run build
bash
php artisan larapack:validate laraimport.json --react
php artisan larapack:import laraimport.json --react
php artisan migrate
php artisan route:json
npm run build
  • Menu. Its list shows up in the admin menu on its own at build time. The interface loads the module with a glob and builds the menu from its routes.
  • route:json. Required: without it, routes.json lacks the new endpoints. Vue reports it as "Unknown backend route".
  • Labels. The model and field names show in English until you translate them in lang/es.json (see "Change the strings" below).

The full contract is in The contract: laraimport.json, and what is generated and where your code goes, in What is generated. Step by step: Your first model.

Decide who sees it

There are two layers, and they are best decided together:

LayerWhereWhat it does
BackendThe model's policy and its ManagedFilterProtects the data. The policy LaraPack generates is closed by default: only an admin passes.
InterfaceadminOnly in config.jsDecides what is shown: the menu group and the route guard.

Admins only. Leave the policy as generated and add the route to adminOnly, so non-admins do not see an entry that would answer them 403:

js
// resources/vue/app/config.js — route names
export const adminOnly = [
    'AdminUsers',
    'AdminProducts',
]
js
// resources/react/app/config.js — route ids
export const adminOnly = ['AdminUsers', 'AdminProducts']

Any signed-in user. Leave it out of adminOnly and open its policy in app/Policies/<Model>Policy.php. If each user should only see their own records, decide that in ManagedFilter::canView. See Routes, immutables, secrets and users.

A menu icon. LaraPack generates no icon (the menu uses box). Add icon to meta (Vue) or handle (React) on the first-level route in resources/<ui>/src/models/<model>/routes/index.js. That is a LaraPack file: --force keeps it because its hash no longer matches, but larapack:verify will report it as customised.

Add a site page

js
// 1. resources/vue/app/router/index.js — creates the /about route, named site.about
export const SITE_PAGES = ['home', 'privacy', 'terms', 'contact', 'join', 'about']

// 2. resources/vue/app/admin/site-editor.js — its tab in the editor
export const DEFAULT_PAGES = ['home', 'privacy', 'terms', 'contact', 'join', 'about']
js
// resources/react/app/config.js — route, name and editor tab at once
export const sitePages = [
    { key: 'home', path: '/', name: 'site.home', label: 'Home' },
    { key: 'privacy', path: '/privacy', name: 'site.privacy', label: 'Privacy' },
    { key: 'terms', path: '/terms', name: 'site.terms', label: 'Terms' },
    { key: 'contact', path: '/contact', name: 'site.contact', label: 'Contact' },
    { key: 'join', path: '/join', name: 'site.join', label: 'Join' },
    { key: 'about', path: '/about', name: 'site.about', label: 'About' },
]
// and "About": "Nosotros" in resources/react/app/lang/es.json

Then build, open /admin/site, open the new tab, add sections, give it a title and save. Also add the link to HeaderOne's nav and to FooterOne's columns.

  • In Vue, a page's path is /<key> (except home, which is /).
  • The editor keeps pages the theme option already had even if they are not in the list, but without a route they cannot be visited.
  • Adding the page to SiteOptionsSeeder only helps a database with no theme: the seeder never touches an existing option.

Add a section

A section is a component registered under <theme>/<group>/<name>. Registering it also makes it available in the editor's select.

vue
<!-- resources/vue/app/site/sections/acme/section/StatsSection.vue -->
<template>
    <section class="site-section">
        <div class="site-container">
            <h2 v-if="filled(title)" class="site-title">{{ title }}</h2>
            <ul v-if="entries.length">
                <li v-for="(entry, index) in entries" :key="index">
                    <strong>{{ entry.value }}</strong> {{ entry.label }}
                </li>
            </ul>
        </div>
    </section>
</template>

<script setup>
    import { computed } from 'vue'
    import { asArray, asObject, asText, filled } from '../../props.js'

    // Props come from hand-written JSON: declare them by name and normalize them.
    // `display` and any extra key must not end up as HTML attributes.
    defineOptions({ inheritAttrs: false })

    const props = defineProps(['title', 'items'])

    const entries = computed(() => asArray(props.items)
        .map(asObject)
        .map((item) => ({ value: asText(item.value), label: asText(item.label) }))
        .filter((item) => item.value !== ''))
</script>
jsx
// resources/react/app/site/sections/acme/section/StatsSection.jsx
import { asArray, asObject, asText, SectionHeader } from '../../shared.jsx'

// In React every section receives a single prop, `props`, with the JSON object.
export default function StatsSection({ props = {} }) {
    const entries = asArray(props.items)
        .map(asObject)
        .filter((item) => asText(item.value) !== '')

    return (
        <section className="site-section">
            <div className="site-container">
                <SectionHeader title={props.title} />

                {entries.length > 0 ? (
                    <ul>
                        {entries.map((entry, index) => (
                            <li key={index}>
                                <strong>{asText(entry.value)}</strong> {asText(entry.label)}
                            </li>
                        ))}
                    </ul>
                ) : null}
            </div>
        </section>
    )
}

Then register it:

js
// resources/vue/app/site/sections/index.js
import StatsSection from './acme/section/StatsSection.vue'

const sections = {
    // …the usual 13
    'acme/section/StatsSection': StatsSection,
}
js
// resources/react/app/site/sections/index.js
import StatsSection from './acme/section/StatsSection.jsx'

export const sections = {
    // …the usual 13
    'acme/section/StatsSection': StatsSection,
}

In the site JSON it looks like this:

json
{ "theme": "acme", "group": "section", "name": "StatsSection", "props": { "display": true, "title": "In numbers", "items": [{ "value": "120", "label": "customers" }] } }
  • Positioned groups. header goes before <main> when it is at the start of the page; footer and cookie-consent go after it when they are at the end. Any other group is content.
  • Helpers. Vue has props.js (asArray, asObject, asText, filled, isTruthy, imageUrl, imageList, textList, initials), SiteLink.vue and social.js. React has shared.jsx (SmartLink, OptionalImage, Brand, SocialLinks, SectionHeader, Checklist, HeroCopy…). Use them for links and images: they filter javascript: and leave no broken gaps.
  • Styles. The site-* classes are in styles/site.css.
  • Tests. If you change a section, the package's interface tests do not cover your copy.

Change the look

Everything is painted with form-core's --fe-* variables: the admin panel, the authentication screens, the site and the tables. To retheme, redefine them at the end of resources/<ui>/app/styles/app.css, which loads after form-core's styles. Do it for the three states form-core uses: if you only change :root, dark mode keeps form-core's colors.

css
/* Light */
:root {
    --fe-primary: #0f766e;
    --fe-primary-hover: #115e59;
    --fe-primary-soft: #ccfbf1;
    --fe-primary-text: #ffffff;
    --fe-focus: #0f766e;
    --fe-radius: 10px;
    --fe-font: 'Inter', system-ui, sans-serif;
}

/* Dark from the system, unless light was chosen */
@media (prefers-color-scheme: dark) {
    :root:not([data-theme='light']) {
        --fe-primary: #5eead4;
        --fe-primary-hover: #99f6e4;
        --fe-primary-soft: #134e4a;
        --fe-primary-text: #042f2e;
        --fe-focus: #5eead4;
    }
}

/* Dark chosen with the button */
:root[data-theme='dark'] {
    --fe-primary: #5eead4;
    --fe-primary-hover: #99f6e4;
    --fe-primary-soft: #134e4a;
    --fe-primary-text: #042f2e;
    --fe-focus: #5eead4;
}
GroupVariables
Backgrounds and borders--fe-bg, --fe-surface, --fe-surface-hover, --fe-surface-raised, --fe-surface-sunk, --fe-border, --fe-border-strong
Text--fe-text, --fe-text-muted, --fe-text-subtle, --fe-text-inverse, --fe-text-xs--fe-text-xl
Color--fe-primary, --fe-primary-hover, --fe-primary-soft, --fe-primary-text, --fe-danger, --fe-danger-hover, --fe-danger-soft, --fe-danger-text, --fe-success, --fe-success-soft, --fe-warning, --fe-warning-soft, --fe-info, --fe-info-soft, --fe-focus
Shape--fe-font, --fe-font-mono, --fe-radius, --fe-radius-sm, --fe-radius-lg, --fe-radius-full, --fe-shadow, --fe-shadow-sm, --fe-shadow-lg, --fe-space-1--fe-space-6
Sizes--fe-density, --fe-control-height, --fe-control-padding-x, --fe-sidebar-width, --fe-dialog-width, --fe-drawer-width, --fe-command-width, --fe-table-max-height

Icons. Components ask for icons by name (home, box, edit…) or by Iconify name (mdi:web). Change the map with setIcons, which merges into the current map, before mounting the application, for example in main.js or main.jsx:

js
import { setIcons } from 'innoboxrr-form-core'

setIcons({ box: 'mdi:package-variant-closed' })

The LaraPack module can ship its own src/theme.js, which the interface imports if it exists.

Change the strings

  • Strings are written in English with t('English key') and translated in resources/<ui>/app/lang/es.json. Change the value, not the key.
  • Placeholders use a colon: "Hello, :name": "Hola, :name".
  • Generated model and field names are translated in that same file: the application's translations load after the module's and win.
  • The language is APP_LOCALE's. For another language, add lang/<locale>.json.
  • Messages from the backend (validation, laravel-auth) come from Laravel and its packages, not from these files.

Other settings

WhatVueReact
Admin-only routesconfig.jsadminOnly (names)config.jsadminOnly (ids)
Site pagesrouter/index.jsSITE_PAGES and admin/site-editor.jsDEFAULT_PAGESconfig.jssitePages
"Administration" group toolsrouter/menu.jsbuildMenuconfig.jsadminTools
Notifications intervalstores/notifications.jsPOLL_INTERVALconfig.jsnotificationsInterval
User updateadmin/ProfileView.vueUSER_UPDATEconfig.jsuserUpdateRoute
Admin prefixrouter/index.jsconfig.jsadminBase, plus the tree and the hard-coded /admin
Where a login landsauth/LoginView.vueauth/LoginView.jsx
Who administersisAdmin() in app/Models/User.phpThe same
Registration open or closedallow-registration in config/laravel-auth.phpThe same

Vue and React differences

Both interfaces honor the same contract, but they are not identical. If you maintain both, or move from one to the other, this is what differs in laravel-setup 7.0.1's code:

TopicVueReact
Settingsconfig.js only exports adminOnly; the rest lives in its own fileconfig.js exports six settings
adminOnlyRoute namesRoute ids
buildMenuReturns a list of groupsReturns { main, admin }
GuardsOne beforeEachA loader on every route
A screen that fails to loadNo error screenErrorView (errorElement)
A request that must not redirect on 401skipAuthHandling: trueskipAuthRedirect: true
A route missing from routes.jsonapiUrl throws "Unknown backend route"route() directly, without that check
BootPromise.allSettled; interceptors after loading the sessionPromise.all (each store catches); interceptors before
CSSform-core, app.css and site.css from main.jsform-core, form-elements.css and app.css from main.jsx; site.css from SitePage
Option update{ option_id, value }{ option_id, name, key, value }
ProfileThe calls are in ProfileView.vueIn the store: updateProfile, updateAvatar, removeAvatar
FieldsIts own FormField.vueTextInputComponent, with a show-password button
Authentication titles"Sign in", "Create account", "Sign out""Log in", "Create an account", "Log out"
Login without a kept sessionMessage on the screenGoes to /admin and the guard sends back to login
Login link to registerKeeps ?redirect=Does not keep it
Forgot / reset passwordNo CSRF cookie request firstRequests the CSRF cookie first
Email when resettingRead-onlyEditable
Log outGoes to /auth/loginGoes to / and clears notifications
Returning from impersonationGoes to /admin or login depending on the sessionGoes to /admin
Resending an already confirmed verificationToast and session reloadSession reload without a toast
Admin homeFirst name; with no entries, a profile cardFull name; with no entries, a message
Bell: load errorMessage inside the panelToast
Bell: "Mark all"Only when there are unreadDisabled when the count is 0
Toast region label"Notifications""Alerts"
Site headerNo dark-mode buttonWith a dark-mode button
FAQAccordion (<details>)All open (<dl>)
Partner logos with external linksNew tabSame tab
Social linksFilters javascript: and similarDoes not filter them
Editor: removing a sectionImmediateAsks for confirmation
Editor: tab namePage title or keysitePages label or key
Empty HtmlContentEmpty sectionNothing
Plans without frequenciesThe object's first priceOnly a price written as text
A section's propsComponent props (defineProps)A single props prop
Vite resolve.dedupeIncludes the innoboxrr-* packagesreact, react-dom, react-router-dom, zustand, axios