Sitemap
Shawn Nystrand

Shawn Nystrand

I have been a developer for about 30 years with a focus on PHP and Python and have served as a Director of Software Development and a Senior Developer.

A 25-Card Dashboard Without a JavaScript Framework: How I Built It With Livewire

11 min readOct 23, 2026

--

Single-file components, self-refreshing cards, per-user personalization and a shared design system, all driven from PHP.

Press enter or click to view image in full size

This is the second in a series of articles about the data aggregation project I’ve been building. Most of the work happened over the last year, though the project has gone through several iterations since its birth as a project to kill some time during the early days of the pandemic. The first article is here. This article discusses the front-end technology I used to build it. Future articles will deep-dive into the back-end technology too.

My personal dashboard’s home page shows about 25 live panels at once: weather, a news feed, sports scores, what’s playing, upcoming launches, a comic, and more. Each one has its own data, its own refresh rate, and its own personalization. A few years ago I’d have reached for a JavaScript single-page app plus a JSON API.

This time I didn’t. I built the entire front end with Livewire, a Laravel library that lets you write interactive components in PHP and handles the browser round trips for you. There’s no build step for components, no client-side state management, and almost no hand-written JavaScript.

This article walks through how the dashboard is put together, what worked, and what I’d change. If you work in Laravel and you’ve never built anything substantial with Livewire, I hope it shows what’s possible.

The stack in one paragraph

Laravel on the backend. Livewire 4 for components. Tailwind CSS v4 with DaisyUI for the look, and a charting component from the MaryUI library for graphs. Alpine.js (which ships with Livewire) for the small client-side bits. Vite bundles the CSS and one tiny JS file. There are about 100 component files, and every page of the site is assembled from them.

The core idea: a page is just a shell

Routes point straight at views, with no controller in between:

Route::view('/', 'pages.dashboard')->name('dashboard');
Route::view('news', 'pages.news')->name('news');
Route::view('weather', 'pages.weather')->name('weather');

A page view does almost nothing. It wraps itself in a layout and arranges components:

<x-layouts::main.app :title="__('Home')">
<div class="grid grid-cols-12 gap-4 xl:gap-6">
<livewire:weather.dashboard.forecast />
<livewire:weather.dashboard.temperature-chart />
<livewire:news.city-headlines :city_id="$pref_city_id" />
<livewire:spotify.dashboard.recent-tracks :limit=50 />
<livewire:sports.dashboard.consolidated-scoreboard />
</div>
</x-layouts::main.app>

The home page is a series of themed sections (“Around You”, “Quick Bits”, “Tunes & Teams”, “Queued Up”), and each section is a 12-column CSS grid of components. That’s the whole page. Everything interesting happens inside the cards.

Single-file components

Livewire 4 lets a component live in one file: PHP class on top, Blade template underneath. Here’s a trimmed real example, the weather forecast card:

<?php
use App\Services\Core\UsersSettingsService;
use App\Traits\WeatherForecastCard;
use Livewire\Component;

new class extends Component {
use WeatherForecastCard;

public bool $show_city_in_title = true;

public function mount(bool $show_city_in_title = true) {
$this->show_city_in_title = $show_city_in_title;
$settings = app(UsersSettingsService::class)->get_settings(auth()->id());
$this->city_id = $settings['pref_my_city'];
$this->get_data();
}
};
?>

<div class="card-surface flex flex-col col-span-full xl:col-span-3"
wire:poll.600s="get_data">
<header>...</header>
@if(!empty($this->data))
@include('partials.main.forecast-body', ['data' => $this->data])
@else
<div class="text-sm text-muted">Weather data is not available yet.</div>
@endif
</div>

I love this format for dashboards. When I want to change a card, everything about it (what it loads, how often it refreshes, how it looks) is in one place on one screen. The files are organized into folders by domain (weather, news, sports, space, music), and the tag name mirrors the path, so <livewire:weather.dashboard.forecast /> lives in weather/dashboard/forecast.blade.php. That makes components easy to find.

Every card has the same anatomy:

  1. mount() runs once when the card is created. It reads the user's settings and loads the first batch of data.
  2. get_data() (my naming convention) fetches fresh data and assigns it to public properties.
  3. The template renders those properties.
  4. A wire:poll attribute on the root element calls get_data() on a timer.

That’s it. Twenty-five cards, one pattern.

Self-refreshing cards: poll at the speed of your data

wire:poll is the magic that makes a dashboard feel alive. Add wire:poll.30s="get_data" to an element and Livewire re-calls that method every 30 seconds and swaps in the changed HTML. No websockets, no JavaScript.

The part I’m proudest of is that each card polls at the speed its data actually changes. Across the app, I use these intervals:

30 seconds: Now-playing music and the admin monitoring panel

60 seconds: The music-stats pages (albums, artists, tracks, overall stats)

5 minutes: Sports scoreboards

10 minutes: Weather, news headlines, sensor charts, article feeds

15 minutes: Traffic events, flights overhead, local headlines, community posts

1 hour: Things that barely change: comics, “today in history”, upcoming launches, movies, TV and album releases

The rule is simple: never poll faster than the backend can produce new data. My scrapers run on a schedule, so a card that polls every 5 seconds just asks the same question over and over. About 40 polling declarations exist across the app, and the most common interval is hourly. A dashboard with 25 cards on a 30-second timer would hammer the server for no benefit.

Livewire also helps here: as I understand its documentation, it throttles polling when the browser tab is in the background, so a dashboard left open overnight shouldn’t generate traffic the way a naive setInterval loop would. (Check the current docs for the exact behaviour.)

Personalization: one dashboard, many users

The dashboard is the same code for every visitor, but it renders differently for each one. Two mechanisms make that work.

1. Settings with defaults. Each user has key/value preferences: home city, which news sections they follow, which sports leagues and favourite teams, and so on. A small service merges stored values over a set of defaults:

public function get_settings(?int $user_id = null): array {
$result = [];
if ($user_id) {
foreach (UserSettings::get_by_user_id($user_id) as $setting) {
$result[$setting->name] = $this->_decode($setting->value);
}
}
return array_merge(self::DEFAULT_SETTINGS, $result);
}

The trick is the ?int $user_id = null. A guest has no id, gets only the defaults, and the dashboard still renders fully. There's no special "logged-out" code path. Logged-out users simply see the default configuration.

2. Components read the settings themselves. The dashboard page looks up the user’s home city once and passes it down as a prop (:city_id="$pref_city_id"), so multiple "around you" cards stay consistent. Individual cards read what they need. The headlines card builds its list of sections from the user's pref_news_sections. The scoreboard reads pref_sports_leagues and highlights favourite teams with a heart. Whole sections can be switched off:

@if(app(UsersSettingsService::class)->get_settings(auth()->id())['pref_dashboard_news'] ?? true)
<livewire:news.dashboard-headlines />
@endif

The settings screen is itself a Livewire component, and a lovely example of how little code a form needs. Public properties hold the preferences, wire:model binds them to inputs, and one save_content_settings() method persists everything:

<form wire:submit="save_content_settings">
<select wire:model="pref_my_city"> ... </select>
<input type="checkbox" wire:model="pref_dashboard_news" class="toggle toggle-primary"/>
</form>

No API endpoint, no fetch call, no form serialization code.

Authorization: components are endpoints

A subtle point that catches people out: every public method on a Livewire component can be called from the browser. A component isn’t just a view. It’s an API surface.

So gating a page with route middleware isn’t enough. My admin monitoring panel checks the permission inside the component, both when it loads and in the method that refreshes it:

public function mount(): void {
abort_unless(auth()->user()?->hasPermission('is_admin'), 403);
$this->load();
}

public function load(): void {
abort_unless(auth()->user()?->hasPermission('is_admin'), 403);
// ...
}

The defence has layers:

  • Route middleware (permission:is_admin) keeps people out of the page.
  • @can checks in templates hide sections a user shouldn't see.
  • abort_unless inside the component means that even someone who crafts a request to the component directly can't get data.

The rule of thumb: authorize in the component, and treat the other layers as convenience.

Components that talk to each other

Most cards are self-contained, but the news page needs coordination. It shows a grid of sections. Click “Regions” on one and the page drills into its child sections. Click “Sources” and it shows the individual outlets. A search box filters everything.

This is built with three tools:

Get Shawn Nystrand’s stories in your inbox

Join Medium for free to get updates from this writer.

Events. A child dispatches an event and the parent listens:

// child
public function drill_into_children(): void {
$this->dispatch('drill-into', key: $this->section_key);
}

// parent
#[On('drill-into')]
public function drill_into(string $key): void {
$this->drill_down = $key;
$this->source_view = null;
}

Reactive props. The search text lives in the parent. Children declare it #[Reactive], so when the parent's value changes, every child updates without being re-mounted. The input is debounced so I'm not querying on every keystroke:

<input wire:model.live.debounce.400ms="search" placeholder="Search headlines…" />

Keys. When rendering lists of components in a loop, each needs a unique :key (for example 'child-'.$child_key). Without keys, Livewire can't tell which child is which when the list changes, and you get stale or mixed-up data.

On the dashboard, the same cards behave slightly differently, because the “drill into” event triggers a redirect to the full news page instead of switching views in place. One component, two behaviours, and the difference is a single listener.

The URL stays honest too: the news page reads ?section= and ?sources= from the request in mount(), so a drilled-in view can be linked and bookmarked.

Responsive design without a second codebase

I built the layout mobile-first with Tailwind classes, and a few small patterns handle the rest.

  • Grid spans: col-span-full xl:col-span-4 means a card fills the row on small screens and takes a third of it on wide ones.
  • Hiding cards: hidden sm:flex removes the detailed scoreboard on phones, where it's too wide to be useful.
  • Scrollable cards: xl:max-h-150 overflow-y-auto caps a card's height on large screens, so a long list doesn't push the layout around.
  • Trimming data on small screens. A news card normally shows 50 headlines. On a phone that’s a wall of text, so it asks for fewer on load using Alpine:
<div x-data x-init="if (window.innerWidth < 640) $wire.set_limit('5')">

Alpine reads the viewport, calls a Livewire method on the server, and the component re-renders with five items. It’s two lines and avoids duplicating components for mobile.

Charts from PHP

Charts are built in the component’s PHP, as a Chart.js configuration array. The component fetches data, shapes it into a config and hands it to the chart component:

$this->chart = [
'type' => 'line',
'data' => [
'labels' => $data['time'],
'datasets' => [[
'label' => 'Temperature',
'data' => $data['temp'],
'fill' => true,
'tension' => 0.4,
'pointRadius' => 0,
]],
],
];
<x-chart wire:model="chart" />

Because the chart is just a public property, the polling mechanism updates it for free. Every poll rebuilds the array and the chart redraws. I never touch a charting library’s API directly.

One design system, five looks

The site is really several small apps: the main dashboard, a music-stats section, a finance section and two small novelty tools. They share infrastructure but each has its own look, so each has:

  • its own layout (a header plus a slot), and
  • its own theme stylesheet.

Underneath, everything uses one set of tokens. DaisyUI themes are defined in CSS as variables (primary, secondary, base colors, and so on), and a custom card-surface class defines what a card looks like, so every card in every app inherits the same border, background and radius. Switching themes changes the variables, and all 100 components pick it up without edits.

Dark mode is a second theme for the main app. A tiny inline script in the document head decides which theme to use before the first paint, from the saved preference or else the operating system setting:

var stored = localStorage.getItem('theme');
var dark = stored ? stored === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches;
if (dark) { html.setAttribute('data-theme', 'main-dark'); }

Without it, dark-mode users get a bright flash on every page load while the real theme loads. The toggle button is a small Alpine component that flips the attribute and saves the choice.

Sharing code between cards

When two components need the same logic, there are three tools, and I use all of them.

  • Traits for shared component behaviour. Several weather cards use one trait that provides the city id, the data array and the get_data() method. The cards differ only in their templates and in how they find the city.
  • Blade partials for shared markup. The forecast body is a partial used by the dashboard card and the full weather page.
  • Reusable components for shared UI, such as a header with an icon and title.

Testing components

Livewire makes components easy to test, because you can mount one and assert on its state without a browser. I have two kinds of tests:

  • Smoke tests render every page against an empty database, to catch broken templates.
  • Data tests seed a realistic database (a user, a city, news articles, a game, tracks) and mount every read-only component against it, so the formatting code actually runs on real-looking rows.

The second kind exercises code paths the empty-database smoke tests never reach, because the data-formatting code only runs when there are rows to format.

Mistakes and what I’d change

Cards call controllers. Many components fetch data through app(SomeController::class)->get_something(), reusing the methods my API already had. It was fast to build, but it blurs the layering: a controller is an HTTP concern, and a component shouldn't depend on one. If I started over, components would call the services directly and the controllers would be thin wrappers over the same services.

Public properties are part of the page. Everything in a public property is serialized into the HTML and sent back and forth on every round trip. A card with a 50-item array of tracks adds real weight to every poll. It also means a public property is visible to the user, so never keep anything sensitive in one. Keep public state minimal.

No lazy loading yet. The home page mounts about 25 components in one request, so the first paint waits for the slowest card. Livewire supports deferring components until after the initial load. That’s the obvious next improvement, and it’d make the page feel faster without changing any data code.

Every card polls independently. Twenty-five cards means up to twenty-five separate requests per interval cycle. They’re spread out by their different intervals, but a shared, batched refresh could cut traffic. For a personal project it hasn’t mattered.

Settings reads are repeated. Several components fetch the same user settings on mount. Caching the settings per request would remove the duplication.

What I’d tell you before you build one

  1. Use one pattern for every card. mount, get_data, wire:poll. Consistency lets you add a card in minutes.
  2. Poll at the speed of the data. If the backend updates hourly, an hourly poll is correct.
  3. Authorize inside components. Anything public is callable.
  4. Give guests defaults. A settings service that merges over defaults means no separate logged-out code path.
  5. Use events and reactive props for coordination, and give looped components keys.
  6. Keep public state small. It travels with every request.
  7. Put design decisions in tokens. One card style and a few CSS variables beat 100 hand-styled components.
  8. Test with realistic data, not just empty states.

I expected Livewire to feel like a compromise compared with a JavaScript framework. Instead, it felt like deleting an entire layer of the project. There’s no API contract to keep in sync, no client state to reconcile, and a new card is one file. For a data-heavy dashboard where the interesting work is on the server anyway, that trade has been a very good one.

Other articles in this series about my personal dashboard project:

  1. One Dashboard to Rule Them All: What I Built, Why, and How
  2. A 25-Card Dashboard Without a JavaScript Framework: How I Built It With Livewire
  3. Inside a News Scraper: Fetching 200+ RSS Feeds Reliably, and What You Actually Get
  4. Building a News Briefing Agent: Heuristics First, LLM Second

--

--

Shawn Nystrand
Shawn Nystrand