Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Nova Custom Table Card Laravel Package

m-a-k-o/nova-custom-table-card

A Laravel Nova card for building custom data tables with full control over columns, queries, and formatting. Show tailored metrics and datasets on your dashboards without writing separate Vue components. Simple to configure and ideal for admin panels that need flexible, at-a-glance reporting.

View on GitHub
Deep Wiki
Context7

Getting Started

Confirm the stack before you install. This package has value only inside a Laravel app that already runs Laravel Nova. Check the package's composer.json (or its GitHub composer.json) against your laravel/nova, laravel/framework, and PHP versions before committing. With 45 stars and 0 dependents, compatibility is your responsibility — treat any mismatch as a hard stop rather than something you'll "fix later."

composer require m-a-k-o/nova-custom-table-card
php artisan nova:publish   # required for the Vue asset; the card renders blank without it

There is no migration and, for the basic case, no config file to publish. Pin the version in composer.json immediately after the first successful render, and note the Nova/Laravel/PHP combo in your README.

Where to look first. Open vendor/m-a-k-o/nova-custom-table-card/src/ and read the card class before writing anything. The public surface is small — something like make(), title(), query()/model(), columns(), limit(), orderBy(), plus inherited Nova helpers (width(), canSee(), onlyOnDetail()). The README covers roughly the same ground, but your installed version's source is the authoritative reference — method names on niche packages drift between releases, and you do not want to debug from a blog post.

First use case: one narrow card on the main dashboard.

<?php

namespace App\Nova\Cards;

use App\Models\Order;
use Laravel\Nova\Http\Requests\NovaRequest;
// Vendor namespace/class name: confirm against vendor/m-a-k-o/nova-custom-table-card/src/
use Mako\NovaCustomTableCard\NovaCustomTableCard;

class LatestOrders extends NovaCustomTableCard
{
    public function title(): string
    {
        return 'Latest Orders';
    }

    public function columns(): array
    {
        // Keys are header labels; values are the raw attribute, or a closure for formatting.
        return [
            'ID'      => 'id',
            'Customer'=> fn (Order $o) => $o->customer->name,
            'Total'   => fn (Order $o) => '$' . number_format($o->total_cents / 100, 2),
            'Placed'  => fn (Order $o) => $o->created_at->diffForHumans(),
        ];
    }

    protected function query()
    {
        return Order::query()->with('customer')->latest()->limit(10);
    }
}

Register it in app/Nova/Dashboards/Main.php (or NovaServiceProvider::cards() if you use the legacy dashboard):

public function cards(NovaRequest $request): array
{
    return [
        (new LatestOrders)->width('1/2'),
    ];
}

If the table renders, you're done with setup. Everything after this is about keeping it fast, scoped, and replaceable.

Implementation Patterns

One card class per concern. Resist the urge to build a generic "table browser." LatestOrders, FailedJobs, PendingPayouts, FlaggedComments as separate small classes is the pattern this package rewards: each has its own query, its own scope, and can be enabled/disabled/canSee()d independently.

Extract a base card for shared scoping. Tenant or role scoping is easy to forget when you're writing the fifth card. Put it in an abstract base class so no card can accidentally ship an unscoped query:

abstract class ScopedCard extends NovaCustomTableCard
{
    protected function scopedQuery()
    {
        return $this->query()->where('tenant_id', request()->user()->tenant_id);
    }
}

Choose the data source deliberately. Eloquent models are the obvious case, but arrays are the escape hatch for things that aren't a single table — queue depth, a Redis counter, a cached API result. Use with() on anything that renders a relationship column (see Gotchas) and select() only the columns you actually display.

Cache anything heavier than a small indexed LIMIT. Dashboard cards are evaluated on every dashboard load, by every admin. If the query touches a big table or aggregates, wrap it:

$orders = Cache::remember(
    "card:latest-orders:{$user->tenant_id}", // key MUST include tenant/user — see Gotchas
    now()->addMinutes(5),
    fn () => Order::query()->where('tenant_id', $user->tenant_id)->latest()->limit(10)->get()
);

Gate visibility in two places. ->canSee(fn ($request) => $request->user()->isAdmin()) hides the card in the UI; the query must also enforce scoping server-side. The card is a rendering component, never the authorization boundary.

Dashboard placement is a decision, not a default. width() ('1/2', '1/3', 'full') controls the grid; onlyOnDetail() keeps a card off the dashboard entirely and on a resource detail page. Pair a "Failed jobs" card with a Nova metric for the count so ops get the number and the rows.

Test the card class directly. You don't need to drive the Vue component to catch 90% of problems:

it('returns at most ten orders for the tenant', function () {
    $card = new LatestOrders;
    expect($card->query()->count())->toBeLessThanOrEqual(10);
});

Assert the columns() array shape too — a typo'd column key is a silent missing column, not an exception.

Plan the exit before you need it. A replacement native Laravel\Nova\Card (or a plain Nova resource index) can consume the same query. Keeping the query logic in the card class rather than in NovaServiceProvider makes that swap a one-file change.

Gotchas and Tips

Blank card almost always means unpublished assets. If the card exists in the DOM but shows nothing, run php artisan nova:publish and confirm the component() string matches the Vue component name registered in vendor/m-a-k-o/nova-custom-table-card/resources/js/. Add nova:publish to your deploy script if it isn't already there — it's a common cause of "it worked locally."

N+1 is the default failure mode. Column closures like fn ($o) => $o->customer->name fire per row. Ten rows is ten queries. Always ->with('customer') on the query, and if you're rendering three relationship columns, eager-load all three. Watch the query log during the spike, not after go-live.

Never ship an unbounded query. There's no framework guardrail here. Always ->limit() and ->orderBy() on an indexed column (created_at usually, id if you have no better option). On a large table without an index, a "harmless" dashboard card will make the main Nova dashboard slow for everyone — and everyone will blame Nova.

Cache keys must include the user or tenant. This is the mistake that turns a convenience card into an incident: caching rows under card:latest-orders and serving them to a different tenant. Key on $user->tenant_id (and the user ID if scoping is per-user), and clear the key on writes that should appear immediately.

canSee() is not authorization. It removes the card from the payload for unauthorised users, but anyone constructing the card another way — or a query that forgot its where — bypasses it. Enforce tenancy and policies in the query itself, or via global scopes on the model.

Closure columns are evaluated server-side at render time. That's convenient but means every dashboard load re-runs every formatter. Keep them pure and cheap — no API calls, no count() on relationships inside a closure. If you need an aggregate, compute it in the query (withCount(), selectRaw).

Empty states need checking once. Confirm the table headers still render when the result set is empty; some card implementations render nothing at all, which looks like a broken card to whoever's looking at the dashboard at 6 a.m. If it's ugly, add a "no records" row via a fallback query or hide the card entirely.

Nova upgrades are the real risk. A minor Nova bump can break the frontend asset or a base-class method. Before upgrading Nova, run the card on staging; keep the pinned package version and a documented fallback (native Nova card, same query) in the repo. Do not put this card on a business-critical or customer-facing dashboard where a blank widget is an outage.

Debugging path: browser devtools → Network → the /nova-api/cards response to see the serialized payload the frontend actually receives. If the data is missing there, the problem is server-side (query(), columns(), scoping); if the data is present but the card is empty, the problem is the asset or component() name. That single check saves a lot of time.

Keep the package out of the critical path. Use it for internal ops/support/content visibility where a missed render is an annoyance, not an incident. If the ask grows into filtering, drill-downs, exports, or per-tenant analytics, that's a new card or a different tool — not another column on this one.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums
rlacerda83/query-parser
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings