Dashboard from Your PHP API (Laravel, Symfony)

A Laravel route returning a dashboard array beside the horizontal bar chart widget it renders.

Return an array from a Laravel route. Laravel turns it into JSON. That's a dashboard widget.

The data is already in your Eloquent models. This is one more route on an app you already have deployed.

The reality

PHP's answer to "I need a dashboard" is usually an admin panel — Filament, Nova, Symfony's EasyAdmin, or a hand-rolled set of Blade views.

They're good at what they're for. An admin panel is a CRUD surface: browse records, edit them, manage relations, with charts bolted on as widgets around the edge. If you need that surface, install one.

The mismatch shows up when you only want the numbers. You end up installing a package with its own asset pipeline, its own conventions and its own upgrade path, so that a page can show today's revenue and last week's signups. Then someone asks whether it can go on the office TV, and the answer is that it's behind your app's login, so no.

The smaller version: one route, returning one array, rendered by something else. Nothing to install, and the result is shareable without giving anyone an account in your admin.

Your data stays in your PHP application

dashboardbase never asks for database credentials and never stores your data. It calls your endpoint over HTTPS and renders what comes back.

Your DB_* config, your Eloquent connection, your API keys for third-party services — all of it stays on your server. There is nothing on our side that could query your database, because nothing on our side knows how.

Your existing scopes still apply too. A global scope or a tenant-aware query that every other route relies on is relied on here in exactly the same way — you're not rebuilding your permission rules inside a reporting tool.

Define Laravel endpoints

A horizontal bar chart is a good first widget, because "top referrers" or "best-selling products" is a query you've almost certainly written already. indexAxis is what turns it on its side for a ranked list:

{
  "title": "Top referrers",
  "data": {
    "header": {
      "title": "Top referrers",
      "subtitle": "Last 30 days"
    },
    "labels": ["Google", "Direct", "Twitter / X", "Newsletter", "Product Hunt"],
    "datasets": [
      {
        "data": [
          { "value": 4200 },
          { "value": 3100 },
          { "value": 1800 },
          { "value": 1200 },
          { "value": 640 }
        ],
        "label": "Visits"
      }
    ],
    "indexAxis": "y"
  }
}
{
  "title": "Top referrers",
  "data": {
    "header": {
      "title": "Top referrers",
      "subtitle": "Last 30 days"
    },
    "labels": ["Google", "Direct", "Twitter / X", "Newsletter", "Product Hunt"],
    "datasets": [
      {
        "data": [
          { "value": 4200 },
          { "value": 3100 },
          { "value": 1800 },
          { "value": 1200 },
          { "value": 640 }
        ],
        "label": "Visits"
      }
    ],
    "indexAxis": "y"
  }
}
{
  "title": "Top referrers",
  "data": {
    "header": {
      "title": "Top referrers",
      "subtitle": "Last 30 days"
    },
    "labels": ["Google", "Direct", "Twitter / X", "Newsletter", "Product Hunt"],
    "datasets": [
      {
        "data": [
          { "value": 4200 },
          { "value": 3100 },
          { "value": 1800 },
          { "value": 1200 },
          { "value": 640 }
        ],
        "label": "Visits"
      }
    ],
    "indexAxis": "y"
  }
}

Note the data points: objects with a value, not bare numbers. In Laravel that's a route and a middleware:

<?php

// routes/api.php
Route::middleware(DashboardbaseSecret::class)
    ->prefix('dashboard')
    ->group(function () {
        Route::get('/top-referrers', function () {
            $referrers = Visit

<?php

// routes/api.php
Route::middleware(DashboardbaseSecret::class)
    ->prefix('dashboard')
    ->group(function () {
        Route::get('/top-referrers', function () {
            $referrers = Visit

<?php

// routes/api.php
Route::middleware(DashboardbaseSecret::class)
    ->prefix('dashboard')
    ->group(function () {
        Route::get('/top-referrers', function () {
            $referrers = Visit

Returning a plain array is enough — Laravel serializes it for you. Paste the URL into a Bar Chart widget and it renders.

The middleware is the workspace endpoint secret check. Every workspace has one, and dashboardbase sends it as an x-dashboardbase-secret header on every request to your endpoints, automatically. It authenticates us to you — it is not an API key for calling dashboardbase:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class DashboardbaseSecret
{
    public function handle(Request $request, Closure $next)
    {
        // Fail loudly rather than serving an unprotected endpoint.
        $secret = config('services.dashboardbase.endpoint_secret')
            ?: throw new \RuntimeException('DASHBOARDBASE_ENDPOINT_SECRET is not set');

        if (! hash_equals($secret, (string) $request->header('x-dashboardbase-secret'))) {
            abort(401)

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class DashboardbaseSecret
{
    public function handle(Request $request, Closure $next)
    {
        // Fail loudly rather than serving an unprotected endpoint.
        $secret = config('services.dashboardbase.endpoint_secret')
            ?: throw new \RuntimeException('DASHBOARDBASE_ENDPOINT_SECRET is not set');

        if (! hash_equals($secret, (string) $request->header('x-dashboardbase-secret'))) {
            abort(401)

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class DashboardbaseSecret
{
    public function handle(Request $request, Closure $next)
    {
        // Fail loudly rather than serving an unprotected endpoint.
        $secret = config('services.dashboardbase.endpoint_secret')
            ?: throw new \RuntimeException('DASHBOARDBASE_ENDPOINT_SECRET is not set');

        if (! hash_equals($secret, (string) $request->header('x-dashboardbase-secret'))) {
            abort(401)

Put DASHBOARDBASE_ENDPOINT_SECRET in your .env and point the config at it. Rotating the secret keeps the previous value working for 24 hours, so nothing breaks mid-rotation.

Symfony is the same shape — a controller returning new JsonResponse([...]), with the check in an event subscriber on kernel.request.

Generate PHP endpoints faster

  • The dashboardbase Skill is free and open source. It teaches Claude Code, Cursor or any skills-capable agent the full contract, so generated Laravel routes and Symfony controllers return the right array shape instead of a plausible one.

  • In-app prompt generation produces a copy-pasteable prompt for whatever AI tool you already use — per widget, or one covering a whole dashboard.

Both give you a reviewable draft you run yourself. The prompt is generated in your browser and nothing executes on our side.

Then paste a response into the endpoint validator and confirm the widget can render it — no account needed.

Where this isn't the right fit

  • You need CRUD. Editing records, managing relations, moderating content — install an admin panel. That's what they're for, and this isn't it.

  • Non-technical teammates need to build their own views. Every widget here starts as a route someone writes.

  • Ad-hoc reporting. If the question changes weekly, a query builder serves you better than redeploying endpoints.

  • The dashboard is a customer-facing feature. Build that into your app.

Add one route and see

Drop the route above into an app you already run, paste the URL into a widget, and judge it from something real. The board reads on your phone in the native iOS and Android apps, and it shares as a link without handing anyone an account.

Worth reading next: why building dashboards yourself is more work than you think, and build vs buy: should you build your own dashboards? for the decision framework.