Search by

markwalet / nova-modal-response

markwalet

A Laravel Nova asset for Modal responses on an action.

Package info

github.com/markwalet/nova-modal-response

pkg:composer/markwalet/nova-modal-response

Fund package maintenance!

markwalet

www.paypal.me/markwalet

Statistics

Installs: 1 083 536

Dependents: 0

Suggesters: 0

Stars: 17

Open Issues: 2

v2.0.0 2026-10-12 23:12 UTC

README

Build Status Total Downloads Latest Stable Version License

Enabling custom modals for action responses in Laravel Nova.

A Nova page with a modal showing a heading, status badges and the highlighted PHP that builds it, split between light and dark mode

More info for this specific feature can be found in the Nova Documentation.

Installation

composer require markwalet/nova-modal-response

This package requires PHP 8.2+, Laravel 12+ and Nova 5.

Upgrading from v1? See UPGRADE.md.

Usage

Return a ModalResponse from your action's handle() method:

use Markwalet\NovaModalResponse\ModalResponse;

public function handle(ActionFields $fields, Collection $models)
{
    return ModalResponse::text('This is way better than that small notification in the bottom right!')
        ->title('Result in a modal');
}

Quick start

For a modal with a single piece of content, use one of the one-line factories:

ModalResponse::text('Export finished.');
ModalResponse::html('<strong>Report ready.</strong> Check your inbox.');
ModalResponse::code(file_get_contents(__FILE__));
ModalResponse::json(['lorem' => 'ipsum', 'dolor' => ['sit', 'amet']]);
ModalResponse::markdown("## Done\n\nImported **42** rows.");
ModalResponse::view('reports.summary', ['report' => $report]);

ModalResponse::json() rendering a highlighted JSON payload

code() and json() are syntax highlighted with highlight.js. Pass highlight: false to turn that off:

ModalResponse::code($log, highlight: false);

Modal chrome

Chain these onto any ModalResponse:

Method Default Description
->title(string) none Text in the modal's header bar.
->size(string) 2xl Modal width: one of Nova's sizes, sm to 7xl.
->closeButton(string) Close Label of the close button.
ModalResponse::text('Hello world')
    ->title('Greeting')
    ->size('7xl')
    ->closeButton('Got it');

Stacking blocks

To combine content, pass an ordered list of blocks to ModalResponse::stack(). Blocks render top to bottom:

use Markwalet\NovaModalResponse\Block;
use Markwalet\NovaModalResponse\ModalResponse;

return ModalResponse::stack([
    Block::heading('Import finished')->large(),
    Block::text('Processed 1,204 rows.'),
    Block::badge('3 warnings')->warning(),
    Block::divider(),
    Block::code($logExcerpt)->language('bash'),
])->title('Result');
  • stack() also accepts a closure that returns the array.
  • A bare string anywhere a block is expected becomes a text block, so 'Processed 1,204 rows.' works as well. Anything else that isn't a block throws an InvalidArgumentException.

Blocks

All blocks are built through the Markwalet\NovaModalResponse\Block factory.

Category Blocks
Text content text · heading · list · divider
Code and data code · json
Inline elements badge · icon · link · inline
Collapsible and tabs collapsible · tabs
Raw HTML html · markdown · view

Text content

ModalResponse::stack([
    Block::heading('Import finished')->large(),
    Block::text('Processed 1,204 rows from customers.csv. Three rows were skipped and need a manual fix.'),
    Block::divider(),
    Block::heading('Skipped rows')->small(),
    Block::list([
        'Row 18: missing email address',
        'Row 211: duplicate customer number',
        'Row 940: unknown country code "UK"',
    ]),
    Block::heading('Next steps')->small(),
    Block::list([
        'Correct the rows in the source file',
        'Re-run the import with "Only failed rows"',
    ])->ordered(),
]);

A large heading, a paragraph, a divider, small headings, a bulleted list and a numbered list

Text

A single paragraph of escaped text. Whitespace and newlines are not preserved; use code or list for multi-line output.

Block::text('The export finished successfully.');
Heading

A heading inside the body (separate from the modal title). Sizes: small(), medium() (default), large().

Block::heading('Summary')->large();
List

A list of escaped strings. Unordered by default; call ordered() for a numbered list.

Block::list(['apples', 'oranges', 'pears']);
Block::list(['Back up', 'Migrate', 'Verify'])->ordered();
Divider

A horizontal rule between blocks.

Block::divider();

Code and data

ModalResponse::stack([
    Block::heading('Query')->small(),
    Block::code($sql)->language('sql'),
    Block::heading('Result')->small(),
    Block::json($customer),
]);

A highlighted SQL query and its JSON result, each with a copy button

Code

A code snippet with a copy button. highlight.js auto-detects the language unless you set one with language(). Call withoutHighlighting() to render plain text.

Block::code('SELECT * FROM users;')->language('sql');
Block::code($log)->withoutHighlighting();
JSON

Takes an array, pretty-prints it as JSON and highlights it. Has a copy button. Supports withoutHighlighting().

Block::json($user->toArray());

Inline elements

ModalResponse::stack([
    Block::inline([Block::icon('check-circle')->success()->large(), 'Paid with Visa', Block::badge('•••• 4242')]),
    Block::inline(['Fulfilment', Block::badge('Shipped')->success()->icon('truck')])->spread(),
    Block::inline(['Priority', Block::badge('High')->warning()])->spread(),
    Block::inline(['Fraud check', Block::badge('Review')->danger()->icon('exclamation-triangle')])->spread(),
    Block::divider(),
    Block::inline([
        Block::badge('B2B'),
        Block::badge('Returning')->info(),
        Block::badge('Verified')->success(),
        Block::badge('Backorder')->warning(),
        Block::badge('Chargeback risk')->danger(),
    ]),
    Block::divider(),
    Block::inline([
        Block::link('Open in Stripe', $stripeUrl)->button()->newTab()->icon('arrow-top-right-on-square', trailing: true),
        Block::link('Download invoice', $invoiceUrl)->icon('arrow-down-tray'),
        Block::link('Customer profile', $profileUrl),
    ]),
]);

Inline rows with an icon, key/value rows with badges, badges in every variant, and links

Badge

A small coloured label. Variants: default() (default), info(), success(), warning(), danger(). Sizes: small(), medium() (default), large(). Add a Heroicon inside the badge with icon($name); pass trailing: true to put it after the label.

Block::badge('Published')->success();
Block::badge('Deploying')->info()->icon('arrow-path', trailing: true);
Icon

A single Heroicon by name. Supports the same variants and sizes as the badge. Unknown names render nothing.

Block::icon('exclamation-triangle')->warning()->large();
Link

A link to a URL. Call button() to render it as a button, newTab() to open it in a new tab, and icon($name, trailing: false) to add an icon. Sizes: small(), medium() (default), large(). A button-style link still just navigates to its URL; it does not run a Nova action.

Block::link('Open invoice', route('invoices.show', $invoice));
Block::link('Download', $url)->button()->newTab()->icon('arrow-down-tray');
Inline

Puts a few small blocks on one horizontal row. Only text, badge, icon and link blocks (and bare strings) are allowed inside; anything else throws an InvalidArgumentException. Alignment: start (default), spread(), center() or end().

Block::inline([
    'Deployment',
    Block::badge('Live')->success(),
])->spread();

Collapsible and tabs

ModalResponse::tabs([
    'Overview' => [
        Block::inline(['Customer', 'Ada Lovelace'])->spread(),
        Block::inline(['Lifetime value', Block::badge('€ 4,210')->success()])->spread(),
        Block::inline(['Open tickets', Block::badge('2')->warning()])->spread(),
        Block::collapsible('Billing address', Block::json($address))->expanded(),
        Block::collapsible('Internal notes', 'Prefers invoices by post.'),
    ],
    'Orders' => [Block::list($orders)],
    'Activity' => ['Last login 3 days ago.'],
]);

Tabs with key/value rows, an expanded collapsible with JSON and a collapsed one

Collapsible

A header the user clicks to show or hide its content. Takes a single block or an array of blocks, which can be any blocks. Starts collapsed; call expanded() to start open. Both expanded() and collapsed() accept a boolean or a closure.

Block::collapsible('Stack trace', Block::code($exception->getTraceAsString()));

Block::collapsible('Details', [
    Block::text('Some explanation'),
    Block::list(['One', 'Two']),
])->expanded($user->isAdmin());
Tabs

Shows one tab at a time, with the labels in a header bar. Pass label => blocks pairs, or Block::tab() instances when you need options. The first tab opens by default; mark another one with active().

ModalResponse::tabs([
    'Overview' => [
        Block::text('Everything went fine.'),
    ],
    Block::tab('Log', [
        Block::code($log)->withoutHighlighting(),
    ])->active(),
]);

ModalResponse::tabs() is shorthand for a stack with a single Block::tabs([...]).

Raw HTML

These render HTML as-is. See Untrusted content below before passing them anything a user controls.

HTML

Renders an HTML string as-is.

Block::html('<p>Read the <a href="/docs">docs</a>.</p>');
Markdown

Compiles GitHub-flavored Markdown to HTML on the server (via Laravel's Str::markdown()). Call file() to treat the argument as a path to a Markdown file; an unreadable path throws an InvalidArgumentException.

Block::markdown('**Bold** and a [link](https://nova.laravel.com).');
Block::markdown(resource_path('docs/release-notes.md'))->file();
View

Renders a Blade view on the server and shows the result as HTML.

Block::view('reports.summary', ['report' => $report]);

Untrusted content

html, markdown and view blocks are rendered without sanitization. Never pass user input, request data or other untrusted content to them without escaping or sanitizing it first. Use text, list or code for untrusted strings; those are always escaped.