Developers

Extending MoneyMate

Architecture, local setup, how to find and edit any existing page's JSX, environment variables, migration conventions, and copy-paste recipes for a new CRUD feature, page-builder block, plan-gated feature, notification, or admin page.

Support does not cover customized code

Everything on this page is provided so you can extend and customize the application — but per CodeCanyon's item support policy, support covers using the item as delivered and fixing genuine bugs in the original code. It does not cover code you've modified, extended, or combined with third-party additions, or issues that only appear after such changes. Once you edit application code, you're on your own for anything related to that change — see Getting support for the full policy.

The stack, in practice

MoneyMate is a standard Laravel 13 application using Inertia.js v3 with React 18 — the web app itself renders through Inertia, not a client-side call to a REST layer. Every "page" is a Laravel controller returning Inertia::render('SomeFolder/SomeComponent', [...props]), matched to a React component under resources/js/Pages/SomeFolder/SomeComponent.jsx. Routing, auth, and validation all stay in Laravel exactly as you already know them; React only owns rendering and client-side interaction. Separately, the product also ships a full token-authenticated REST API for third-party integrations — see that section for the complete endpoint list.

If you've built a normal Laravel + Blade app before, the mental model is: replace every return view(...) with return Inertia::render(...), and replace the Blade template with a React component that receives the same data as props instead of view variables. There's no client-side router to configure either — Inertia's <Link> component makes a real request to a real Laravel route, and the server decides what page component comes back, exactly like a traditional server-rendered app, just without the full-page reload.

Since Laravel 11, new projects no longer have an app/Http/Kernel.php or app/Console/Kernel.php — that structure was replaced by a single bootstrap/app.php, and this Laravel 13 project follows that same current convention. Middleware registration, route file loading, and exception handling all live in that one file. If you've only worked with Laravel 10 or earlier, that's where to look first instead of a Kernel class.

Architecture principles

A few conventions repeat throughout the codebase — knowing them up front makes everything else in this guide easier to follow:

Thin controllers, real services

Controllers validate input, call a service or repository, and return an Inertia response. Actual business logic (report queries, plan-limit checks, the debt payoff simulation, package/Stripe sync) lives in app/Services, not inline in a controller method.

One enforcement point per rule

PackageLimitService is the only place that ever answers "can this user do X" — controllers call it rather than re-implementing a check. Copy this pattern for your own gated features instead of scattering if ($user->package->...) checks around.

The real path, not a parallel one

AI-assisted actions (auto-categorize, natural-language entry) and bulk import both create records through the exact same ExpenseService/IncomeService methods a manual form submission uses — never a separate insert path that could drift out of sync with validation or side effects (budget recalculation, rule matching, etc).

Two-track database changes

Every schema/content/translation change ships as both a seeder update (for fresh installs) and a small, non-destructive migration (for installs already running in the wild). See Database & migration conventions below.

Local development setup

composer install
npm install
cp .env.example .env
php artisan key:generate
php artisan migrate --seed
npm run dev

npm run dev starts Vite's dev server with hot module replacement — leave it running while you work. For a one-off production build instead, use npm run build (see below).

  • Run the test suite: php artisan test (or ./vendor/bin/pest directly — the project uses Pest)
  • Code style: ./vendor/bin/pint formats PHP to the project's Laravel-style ruleset; run ./vendor/bin/pint --dirty to only touch files you've actually changed
  • Seed demo data any time: php artisan db:seed --class=DemoDataSeeder — safe to re-run, populates the 10 demo subscriber accounts described in Installation. It creates no admin account, and its accounts get a random unrecorded password unless you set DEMO_SEED_PASSWORD in .env first

Environment variables

Most configuration is meant to be set through the admin panel after installation (see System Settings and AI Providers) rather than by hand-editing .env — the installer writes the core ones for you. The values you're most likely to touch directly during development:

VariablePurpose
APP_ENVlocal for development, production on a live server — affects error detail shown, config caching behavior, and a few installer safety checks.
APP_DEBUGSet to false in production — a debug page that leaks stack traces and env values is a real security risk on a live financial app.
APP_URLMust match your real domain exactly (including https://) — used to generate absolute URLs in emails, Stripe redirects, and signed data-export links.
DB_*Database connection — written by the installer's Database step, or set manually if you're skipping the installer entirely.
QUEUE_CONNECTIONdatabase by default (needs a queue worker — see cPanel Hosting or VPS Hosting), or sync to run queued jobs inline with no worker at all.
SESSION_DRIVERdatabase by default; fine to leave as-is unless you have a specific reason to change it (e.g. Redis at scale).

Stripe keys, SMTP credentials, and AI provider API keys are not read from .env at runtime — they're stored encrypted in the database via the admin panel (ProviderSetting model) so they can be changed without redeploying or restarting anything. If you're grepping the codebase looking for where STRIPE_SECRET or similar is used, that's why you won't find it wired to config() the usual Laravel way.

Installable as a PWA

The app ships a web app manifest, served dynamically at runtime rather than as a static build-time file — so a browser's "Install app" prompt picks up whatever name and icon are currently set in Site Settings (application_name, favicon), with no rebuild needed after changing either. Visiting the app in a supporting browser (Chrome, Edge, and most mobile browsers) offers to install it as a standalone app — no separate app-store build, wrapper, or Capacitor/Cordova-style shell involved.

Compiling frontend assets for production

Whenever you change anything under resources/js or resources/css, rebuild the compiled assets before deploying:

npm install
npm run build

This runs Vite's production build and writes hashed, minified bundles into public/build, along with a manifest.json Laravel's @vite Blade directive uses to resolve the correct file. Commit or upload the contents of public/build along with your code — the production server does not need Node.js installed at all if you deploy pre-built assets (this is exactly what the cPanel hosting path assumes, since most shared hosts don't offer Node).

Forgetting to rebuild is the #1 "my change isn't showing up" bug

Laravel serves the React/JS bundle Vite already built — editing a .jsx file directly on the server does nothing until you run npm run build again (or run npm run dev locally while developing).

Editing an existing page's UI

This is the single most common thing a buyer customizing this application wants to do — change a label, tweak a layout, add a field to an existing form, restyle a card — on a screen that already exists, rather than building something new. The process is always the same four steps:

  1. Note the URL

    Open the page you want to change in the browser and look at the address bar — say /expense.

  2. Find the route

    Search routes/web.php for that URI (grep -n "'expense'" routes/web.php) to find which controller and method handle it — for example ExpenseController::index.

  3. Find the Inertia component name

    Open that controller method and look for its Inertia::render('SomeFolder/SomeComponent', [...]) call — that string is a direct path.

  4. Edit the matching JSX file

    Inertia::render('Expenses/Index', ...) means the file is resources/js/Pages/Expenses/Index.jsx — open it, make your change, save.

Worked example — changing the "Add Expense" button's label and adding a new read-only field to that form:

grep -n "expense" routes/web.php
# ...
#   Route::get('/', [ExpenseController::class, 'index'])->name('expense.index');

# app/Http/Controllers/ExpenseController.php
#   return Inertia::render('Expenses/Index', [...]);

# → the file to edit is:
resources/js/Pages/Expenses/Index.jsx

Inside that file, the button is a plain JSX element like any other React code:

<button type="button" className="btn btn-primary" onClick={() => modalRef.current.show()}>
    <IconCalendarDollar size={18} /> {t('add_expense')}
</button>

Change the icon, the classes, or wrap it differently — it's ordinary JSX/Tabler markup, nothing framework-specific to learn beyond what's already on the page. If the text comes from t('add_expense') rather than being hardcoded, change the actual wording in Translations instead of the JSX (see the callout below) — editing the JSX only changes which translation key is looked up, not the words shown.

To add a new field to the same form, find the <form> further down the same file (search for id="addExpense" or the modal's <Drawer>/modal wrapper), copy an existing field block (label + input + value={data.x} + onChange={(e) => setData('x', e.target.value)}), and rename it. Remember: a genuinely new field also needs a matching column (migration), a spot in the controller's validate()/FormRequest rules, and to be included wherever the record is created/updated — the JSX half alone only gets you a field that doesn't actually save.

Quick reference — "I want to change X"

You want to change...Edit this
Marketing site wording/images (Home, Features, Pricing, etc.)Nothing in code — use the Page Builder. This content is database-driven by design.
User-panel sidebar links, order, iconsresources/js/Layouts/AppLayout.jsx — the NAV_GROUPS array
Admin sidebar links, order, iconsresources/js/Layouts/AdminLayout.jsx — the NAV_GROUPS array
The topbar (theme switcher, notification bell, account dropdown)Same two layout files — the <header className="admin-topbar"> block near the bottom of each
A specific page's layout, text, or fieldsThat page's own file under resources/js/Pages/... — see the four-step process above
A dashboard widget's label or numberresources/js/Pages/Dashboard/Index.jsx (user panel) or resources/js/Pages/Admin/Analytics/Index.jsx (admin) — each widget is a <MetricCard>/<ChartCard> call with its own props
A modal/create form shared across a page (add fields, reorder, change validation messaging)The same page file — the modal markup lives directly below the list/table in the same component, not a separate file
Colors, spacing, or component look across the whole admin/app panelresources/css/admin-tokens.css (CSS custom properties — colors, radii, shadows) and resources/css/admin.css (component styles built from those properties)
Colors or layout across the whole public marketing siteresources/css/public.css (its own separate set of CSS custom properties, deliberately not shared with the admin/app panel)
A specific page-builder block's markup (Banner, Feature Grid, etc.)resources/js/Components/Blocks/BlockName.jsx — see adding a block type for the full pattern this follows
Any UI string ("Add Expense", a validation message shown via t(...))Translations in the admin panel, not the JSX — only add/rename a translation key in code, never hardcode new wording directly into a component
Shared vs. page-specific

Before editing, check whether what you're looking at is actually defined on the page you're viewing, or inherited from something shared — AppLayout.jsx/AdminLayout.jsx (sidebar, topbar), a component under resources/js/Components (used by many pages), or resources/css/admin.css/public.css (global styles). Editing a shared file changes every page that uses it — exactly what you want for something like the sidebar, but a common mistake if you only meant to change one page and accidentally edited a component ten other pages also render.

The edit → verify loop

  1. Run the dev server

    npm run dev and keep it running. Every save to a .jsx/.css file hot-reloads in the browser within a second or two — no manual refresh, no rebuild step, while you're actively working locally.

  2. Check both themes

    Toggle light/dark mode (the theme switcher in the topbar) after any styling change — a color that reads fine in light mode is a surprisingly common way to end up invisible in dark mode, since both panels and the public site fully support both.

  3. Check the actual data states, not just the happy path

    An empty list, a very long value, and a value near a plan limit are the three states most likely to reveal a layout you only tested against tidy demo data.

  4. Build before deploying

    npm run build, then upload the regenerated public/build folder — see Compiling frontend assets above. A change that looks correct under npm run dev locally still does nothing on the live server until this step happens.

Where things live

PathWhat's there
app/Http/ControllersOne controller per feature area; admin-only controllers live under Admin/
app/ModelsEloquent models
app/ServicesBusiness logic that doesn't belong in a controller — PackageLimitService, report repositories, the debt payoff planner calculator, etc.
app/NotificationsEvery email/in-app notification class
app/Console/CommandsScheduled commands (see routes/console.php for when each one runs)
resources/js/PagesOne React component per Inertia page, mirroring the controller structure
resources/js/LayoutsAppLayout.jsx (user panel shell), AdminLayout.jsx (admin shell), PublicLayout.jsx (marketing site shell)
resources/js/ComponentsShared UI components, including Components/Blocks (page-builder block renderers) and Components/Admin (shared admin widgets like MetricCard/ChartCard)
app/Support/BlockRegistry.phpServer-side source of truth for page-builder block types and their default content
resources/js/blockSchemas.jsClient-side mirror of the same registry, used by the page-builder editor UI
database/seeders/PackageSeeder.phpThe three default pricing tiers and every feature/limit flag
database/seeders/TranslationSeeder.phpEvery UI string for a fresh install, in English
routes/web.php / routes/console.phpHTTP routes and the scheduler, respectively
app/Http/RequestsForm request validation classes, including the admin-only ones under Admin/
app/RepositoriesQuery-building classes for data that's read in more than one shape — the expense/income report repositories are the main example
database/migrationsSchema changes, in the order they run — see the naming convention below
database/factoriesModel factories used by the test suite
tests/Feature / tests/UnitPest test files — see Testing conventions

Database & migration conventions

This project maintains two installation paths that must never drift apart: a brand-new install (which runs every migration from scratch, then a seeder) and an existing install being upgraded (which only runs whatever new migrations have shipped since the last update). Every schema, content, or translation change is written to work correctly on both:

  1. The migration is the source of truth for existing installs

    A real schema change (a new column, table, or index) is always a migration under database/migrations, named with a date prefix like 2026_07_28_000001_add_weekly_digest_package_gating.php. Content-only changes (new translation strings, a backfilled default value on existing rows) are also shipped as a migration using DB::table(...)->updateOrInsert(...) — non-destructive by design, so it never overwrites an admin's own edited content or a translator's non-English strings.

  2. The seeder is the source of truth for fresh installs

    Whatever the migration changed for existing installs must also be reflected in the relevant seeder (PackageSeeder, TranslationSeeder, BuilderPageSeeder, MenuSeeder, etc.) so a brand-new install ends up in the exact same state without needing every historical migration's content logic to "replay" correctly. Concretely: if you add a translation key via a migration, add the identical key/value pair to TranslationSeeder.php too — it truncates and re-seeds the whole translations table on a fresh install, so a key that only exists in the migration would be silently missing.

  3. Never edit an already-shipped migration

    Once a migration has been included in a release, treat it as immutable — anyone who already installed or updated will have already run it. A follow-up change is always a new migration, even to fix a mistake in an earlier one.

Recipe: a brand-new CRUD feature end-to-end

Most of the personal-finance features in this application (Bank Accounts, Liability Accounts, Savings Goals, and so on) are the same shape: a user-owned model, a repository, a thin service, a controller, a form request, and a single Inertia page handling list/create/edit/delete without separate routes for each. Here's that shape, start to finish, using a hypothetical Vehicle feature as the example:

  1. Migration & model

    Create the table (always include user_id and scope every query to it) and an Eloquent model with $fillable and any casts. Mirror an existing simple model like App\Models\AssetAccount for the shape.

  2. Repository

    A class with getByUserId(), create(), update(), delete() methods — this is where you'd compute a derived value (like converting to a reporting currency) so the controller stays thin.

  3. FormRequest

    Validation rules for store/update, scoped so a user can only reference their own related records (e.g. Rule::exists('currencies', 'id'), or Rule::exists('bank_accounts', 'id')->where('user_id', $userId) for anything owned).

  4. Controller

    index() returns the Inertia page with the user's existing records; store()/update()/destroy() are simple redirects back to index with a flash message (via the notyf() helper already used throughout the app). Wrap any multi-step write in DB::transaction().

  5. Routes

    Add a Route::group(['prefix' => 'vehicles'], ...) block to routes/web.php inside the authenticated group, named vehicles.index/vehicles.store/etc., matching the naming pattern every other feature in the file already uses.

  6. Inertia page

    One React component at resources/js/Pages/Vehicles/Index.jsx: a table of existing records, and a modal (see any existing simple CRUD page, like SavingsGoals/Index.jsx, for the exact modal + useForm pattern) for create/edit.

  7. Navigation entry

    Add { key: 'vehicles', route: 'vehicles.index', label: 'vehicles', icon: IconCar } to the relevant group in NAV_GROUPS inside resources/js/Layouts/AppLayout.jsx, and the matching translation key/value in TranslationSeeder.php (plus a migration if the app is already installed anywhere — see above).

  8. Gate it behind a plan, if it should be (optional)

    Follow the plan-gating recipe below.

  9. Tests

    A feature test covering ownership scoping (user A can never see/edit user B's records) at minimum — this is the single most common real bug class in a multi-tenant CRUD feature.

Recipe: adding a new page-builder block type

Every marketing-page content block (Banner, Feature Grid, Pricing Table, etc. — see Page Builder & Menus) follows the same four-file pattern. To add a new one, say a video_embed block:

  1. Register the type server-side

    Add 'video_embed' to the array in App\Support\BlockRegistry::types(), and a default content shape in defaultContent() — this is the only server-side validation of what type values are allowed.

  2. Mirror it client-side

    Add a matching entry to BLOCK_SCHEMAS and DEFAULT_CONTENT in resources/js/blockSchemas.js — this drives the admin editor's field list (label, kind: text/textarea/richtext/select/image/repeatable) and instant local preview.

  3. Build the renderer

    Create resources/js/Components/Blocks/VideoEmbed.jsx — a plain React component receiving { content } and rendering the public-facing HTML for that block.

  4. Wire it up

    Add video_embed: VideoEmbed to BLOCK_RENDERERS in resources/js/Components/Blocks/index.js.

That's it — the block immediately appears in the admin builder's block palette and renders on any page it's added to. No database migration needed; block content is a JSON column.

Recipe: adding a new plan-gated feature

Every feature you can turn on/off per pricing tier follows the same pattern (see Packages):

  1. Add the limit key

    Add a new key under features.limits for each tier in database/seeders/PackageSeeder.php (and add it to the existing package rows via a migration if the app has already been installed anywhere).

  2. Add it to the admin form

    Add an entry to the appropriate group in FEATURE_GROUPS inside resources/js/Components/Admin/PackageForm.jsx, and to the validation whitelist in StorePackageRequest/UpdatePackageRequest.

  3. Gate the real feature

    In the controller/service that powers the feature, call PackageLimitService::isFeatureEnabled($user, 'your_new_key') (boolean flags) or ::canCreate($user, 'your_new_key', $currentCount) (numeric limits, where true stored in the database means unlimited).

  4. Advertise it (optional)

    Add it to PackagePublicDataService::COMPARISON_ROWS so it shows up automatically in the public Pricing page's comparison table.

Recipe: adding a new notification preference

User-controllable notification toggles (see Notification Preferences) live on the NotificationPreference model. Add a new boolean column via a migration, add it to NotificationPreferenceController's only()/validate() lists, and read it from your Notification class's via() method — the established pattern is return $preference->your_flag ? ['database', 'mail'] : ['database']; so the in-app bell always fires but email is opt-in.

Recipe: adding a new admin settings page

Every screen under the admin sidebar (see Admin Panel) follows the same shape as a normal CRUD feature, with two differences: the controller lives under app/Http/Controllers/Admin, and the route is registered inside the admin middleware group.

  1. Controller & routes

    Add your controller under Admin/, and register its routes inside the existing Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.') group in routes/web.php — the admin middleware alias (EnsureUserIsAdmin) is what actually blocks non-admin users with a 403.

  2. Inertia page

    Create the page under resources/js/Pages/Admin/YourFeature/Index.jsx, wrapped in <AdminLayout> rather than <AppLayout> — this is what gives you the admin sidebar/topbar shell instead of the user-panel one.

  3. Sidebar entry

    Add an entry to the appropriate group (or a new group) in NAV_GROUPS inside resources/js/Layouts/AdminLayout.jsx.

  4. Reuse the shared admin components

    PageHeader, EmptyState, StatusBadge, RowActionsMenu, Drawer, MetricCard, and ChartCard (all under resources/js/Components/Admin) cover the vast majority of admin UI patterns already — a table with a "..." row-actions menu, a slide-out create/edit drawer, an empty state, a dashboard-style metric card. Reach for these before building a one-off equivalent.

Frontend conventions

  • UI library: the admin and user panels use the Tabler class-based design system (plain Bootstrap-flavored classes like card, btn btn-primary, table table-vcenter) — there's no component library abstraction layer to learn, just apply the same classes Tabler's own docs describe.
  • The public marketing site is a separate design system — its own CSS custom properties and public-*-prefixed classes in resources/css/public.css, deliberately not mixed with Tabler.
  • Translations: every visible string in the user/admin panels goes through t('some_key') from useSharedProps(), resolving against the translations Inertia shared prop for the current locale (falling back to the raw key if missing — a quick way to spot an untranslated string during development). Add new keys to TranslationSeeder.php and a migration, per the conventions above.
  • Icons: @tabler/icons-react throughout — the page-builder's feature-grid blocks even resolve an icon by string name at runtime (TablerIcons[item.icon]), so admin content can reference any icon in the set without a code change.
  • Forms: Inertia's own useForm() hook is used for every create/edit form — it tracks field state, in-flight submission, and validation errors from the last response together, without a separate form library.

The built-in REST API

MoneyMate doesn't just expose a couple of endpoints — it ships a full, already-built REST API under routes/api.php, versioned at /api/v1, covering essentially every core money-management resource in the app. Every route requires a subscriber's personal access token — generated from API Tokens, issued via Laravel Sanctum — sent as Authorization: Bearer <token>, and both the auth:sanctum and a custom EnsureApiAccessEnabled middleware run on every request, so a token only works for plans with the api_access feature enabled. The full request/response shape for each endpoint is documented in-app, right on the API Tokens page.

Resources covered, all scoped to the token's owner by an explicit where('user_id', ...) lookup in each controller — not plain route-model binding, so no route can resolve another subscriber's record by id:

  • GET /user — the authenticated subscriber's profile
  • Full CRUD on incomes and expenses
  • Full CRUD (except create/edit views) on budgets, tags, recurring-transactions, and transaction rules
  • Full CRUD on savings-goals, plus a POST /savings-goals/{id}/contribute action
  • debts — list, create, show, delete, plus repay and collect actions
  • GET/POST on accounts, categories, and banks
  • GET/POST on account transfers
  • Read-only currencies and net-worth

That's 15 resources and 50+ endpoints already built, tested, and gated behind the same plan-limit and validation logic as the web app — ready to hand to buyers building a mobile app, a Zapier-style automation, or a public API product on day one, with no extra backend work required.

To expose more endpoints of your own, add routes to routes/api.php inside the existing auth:sanctum + EnsureApiAccessEnabled group, and reuse the same services/repositories the Inertia controllers already call. The business logic layer described in Architecture principles is what makes this safe to do without duplicating validation or plan-limit checks.

Testing conventions

The project uses Pest. Feature tests live in tests/Feature (grouped into subfolders like Admin/ and Public/ where relevant) and unit tests in tests/Unit. Run the whole suite with:

php artisan test

A typical feature test authenticates as a user, performs the request, and asserts on both the HTTP response and the resulting database state:

test('a user can create a savings goal', function () {
    $user = User::factory()->create();

    $this->actingAs($user)->post(route('savings-goals.store'), [
        'name' => 'Emergency fund',
        'target_amount' => 5000,
    ])->assertRedirect();

    expect(SavingsGoal::where('user_id', $user->id)->where('name', 'Emergency fund')->exists())
        ->toBeTrue();
});

New features should ship with tests that cover the actual correctness claim being made (a limit really blocks at the boundary, a notification really respects the preference, a calculation really matches the formula, one user can never see another user's records) — not just that a page returns a 200.

Licensing (CodeCanyon purchase code verification)

Seller only The web installer can require a valid Envato purchase code before anyone can proceed past the first step. This is entirely optional and off by default — it only activates once two values are set in .env: ENVATO_PERSONAL_TOKEN and ENVATO_ITEM_ID. Leave them empty for local development; the license step is skipped completely when they're unset, so this never gets in your way while building or testing.

  1. Generate a scoped token

    At build.envato.com/create-token, create a personal token with only the "Verify Purchase Codes" permission. Don't grant any broader scope — this token ends up inside the ZIP you upload to CodeCanyon, so it should only be able to do the one thing this feature needs.

  2. Find your item ID

    Once the item is listed, its numeric ID is in the item's CodeCanyon URL and in your Envato author dashboard.

  3. Set both values before packaging

    Add ENVATO_PERSONAL_TOKEN and ENVATO_ITEM_ID to the .env that ships inside your distributable ZIP — not the .env.example committed to your own repo, and not your local development .env. With both set, a buyer running the installer sees a "Verify Your Purchase" step first; entering a purchase code that doesn't match this item's ID, or doesn't verify at all, is rejected.

Verification happens once, during installation — the result (buyer username, license type, verification timestamp, and a hash of the purchase code) is stored in site settings, not re-checked on every request. This keeps the app usable offline after install and avoids "phoning home" on a schedule.

The purchase code itself is never stored — only a SHA-256 hash of it, kept for support reference.

Deployment checklist

After pulling code changes onto a live server:

  1. Install dependencies

    composer install --no-dev --optimize-autoloader and, if you changed any frontend code, npm install && npm run build on a machine with Node — the server itself never needs Node or Composer if you upload the already-built vendor/ and public/build folders as-is (see cPanel Hosting or VPS Hosting).

  2. Run new migrations

    php artisan migrate --force — never migrate:fresh against a live database with real subscriber data.

  3. Rebuild caches

    php artisan config:cache, php artisan route:cache, php artisan view:cache.

  4. Restart the queue worker, if applicable

    A running queue:work process caches the code it started with — restart it (or let the next cron-triggered --stop-when-empty run pick up the change automatically) so queued jobs use your updated code.