A few days ago, I needed a simple tool: a lightweight desktop app to notify me or my team about internal announcements—things like server maintenance, meeting reminders, or company-wide messages. I didn’t want to build something heavy with Electron or jump into JavaScript land.
Since I work mostly with PHP and Laravel, I gave NativePHP a shot—and surprisingly, I managed to get a fully working announcement app in just a few hours using Laravel 11. Here’s how I did it, step by step.
What I Wanted
The goal was simple:
- A desktop app (for macOS/Windows) built with Laravel
- Show system notifications when new announcements are available
- Pull announcements from a JSON file (or an API)
- Track which announcements were already shown
Getting Started: The Foundation
First things first, I set up a fresh Laravel 11 installation. If you haven’t worked with Laravel 11 yet, the installation process is pretty much the same as before:
composer create-project laravel/laravel announcement-app
cd announcement-app
php artisan serve
Planning the Data Structure
Here’s where I spent more time than I’d like to admit. Initially, I was going to use a traditional database setup with migrations and all that jazz. But then I realized something: for an announcement system, I don’t really need complex relationships or heavy database operations. Most of the time, I’m just displaying a list of announcements and tracking which ones users have seen.
So I went with a JSON file approach. Controversial? Maybe. Practical for this use case? Absolutely.
I created two JSON files:
storage/app/announcements.jsonfor the actual announcementsstorage/app/seen.jsonfor tracking what users have already seen
Here’s what my announcements.json looked like:
[
{
"id": 1,
"title": "Announcement 1",
"message": "The system will undergo maintenance at 2 AM."
}
]
And seen.json starts as an empty array:
[]
Building the Controller
This is where things got interesting. My first attempt at the controller was… let’s call it “ambitious.” I was trying to do too much in one method. After some refactoring (and a coffee break), here’s what I ended up with:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
class AnnouncementController extends Controller
{
public function index()
{
$jsonPath = storage_path('app/announcements.json');
$seenPath = storage_path('app/seen.json');
$announcements = file_exists($jsonPath)
? json_decode(file_get_contents($jsonPath), true)
: [];
$seen = file_exists($seenPath)
? json_decode(file_get_contents($seenPath), true)
: [];
return view('announcements', compact('announcements', 'seen'));
}
public function store(Request $request): JsonResponse
{
$request->validate([
'title' => 'required|string|max:255',
'message' => 'required|string|max:1000',
]);
$jsonPath = storage_path('app/announcements.json');
$announcements = file_exists($jsonPath)
? json_decode(file_get_contents($jsonPath), true)
: [];
$newId = empty($announcements) ? 1 : max(array_column($announcements, 'id')) + 1;
$newAnnouncement = [
'id' => $newId,
'title' => $request->title,
'message' => $request->message,
'created_at' => now()->format('Y-m-d H:i:s')
];
$announcements[] = $newAnnouncement;
file_put_contents($jsonPath, json_encode($announcements, JSON_PRETTY_PRINT));
return response()->json([
'success' => true,
'message' => 'Announcement added successfully',
'announcement' => $newAnnouncement
]);
}
public function clearSeen(): JsonResponse
{
$seenPath = storage_path('app/seen.json');
if (file_exists($seenPath)) {
unlink($seenPath);
}
return response()->json([
'success' => true,
'message' => 'Seen announcements cleared'
]);
}
}
I know what you’re thinking – “that’s a lot of private methods.” You’re right, but here’s the thing: each method has one job, and when I inevitably need to debug something at 2 AM, I’ll thank myself for keeping things organized.
Creating the Views
The view was probably the most fun part of this project. I went with a clean, card-based design that doesn’t scream “developer made this.” Here’s my Blade template:
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Announcement Demo</title>
<meta name="csrf-token" content="{{ csrf_token() }}">
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background: #f5f5f5;
}
.container {
background: white;
border-radius: 8px;
padding: 30px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
input, textarea {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
button {
background: #007bff;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
margin-right: 10px;
}
button:hover {
background: #0056b3;
}
.btn-danger {
background: #dc3545;
}
.btn-danger:hover {
background: #c82333;
}
.announcement-item {
background: #f8f9fa;
padding: 15px;
margin: 10px 0;
border-radius: 4px;
border-left: 4px solid #007bff;
}
.seen {
opacity: 0.6;
border-left-color: #28a745;
}
.status {
margin: 20px 0;
padding: 10px;
border-radius: 4px;
}
.status.info {
background: #d1ecf1;
color: #0c5460;
}
.status.success {
background: #d4edda;
color: #155724;
}
</style>
</head>
<body>
<div class="container">
<h1>🔔 Announcement Demo App</h1>
<div id="status" class="status info">
<strong>Status:</strong> Running background job every minute to check for new announcements
</div>
<div class="form-section">
<h3>Add New Announcement</h3>
<form id="announcementForm">
<div class="form-group">
<label for="title">Title:</label>
<input type="text" id="title" name="title" required>
</div>
<div class="form-group">
<label for="message">Message:</label>
<textarea id="message" name="message" rows="4" required></textarea>
</div>
<button type="submit">Add Announcement</button>
<button type="button" id="clearSeen" class="btn-danger">Clear Seen List</button>
</form>
</div>
<div class="announcements-section">
<h3>List announcements ({{ count($announcements) ?? [] }} items)</h3>
<div id="announcementsList">
@forelse($announcements ?? [] as $announcement)
<div class="announcement-item {{ in_array($announcement['id'], $seen) ? 'seen' : '' }}">
<h4>{{ $announcement['title'] }}
@if(in_array($announcement['id'], $seen))
<span style="color: #28a745;">✓ Seen</span>
@else
<span style="color: #dc3545;">● Unseen</span>
@endif
</h4>
<p>{{ $announcement['message'] }}</p>
<small>ID: {{ $announcement['id'] }}</small>
</div>
@empty
<p>No announcements found</p>
@endforelse
</div>
</div>
<div class="debug-section">
<h3>Debug Info</h3>
<p><strong>Seen IDs:</strong> {{ implode(', ', $seen) ?: 'Không có' }}</p>
<p><strong>Total announcements:</strong> {{ count($announcements) }}</p>
<p><strong>Unseen:</strong> {{ count($announcements) - count($seen) }}</p>
</div>
</div>
<script>
// Set CSRF token for AJAX requests
const token = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
document.getElementById('announcementForm').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(this);
try {
const response = await fetch('/add-announcement', {
method: 'POST',
body: formData,
headers: {
'X-CSRF-TOKEN': token
}
});
const result = await response.json();
if (result.success) {
document.getElementById('status').innerHTML = `<strong>✅ Success:</strong> ${result.message}`;
document.getElementById('status').className = 'status success';
this.reset();
setTimeout(() => location.reload(), 1500);
}
} catch (error) {
console.error('Error:', error);
document.getElementById('status').innerHTML = `<strong>❌ Error:</strong> ${error.message}`;
}
});
document.getElementById('clearSeen').addEventListener('click', async function() {
if (!confirm('Are you sure you want to clear the seen list?')) return;
try {
const response = await fetch('/clear-seen', {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': token
}
});
const result = await response.json();
if (result.success) {
document.getElementById('status').innerHTML = `<strong>✅ Success:</strong> ${result.message}`;
document.getElementById('status').className = 'status success';
setTimeout(() => location.reload(), 1000);
}
} catch (error) {
console.error('Error:', error);
}
});
// Auto refresh every 30 seconds to show updates
setInterval(() => {
location.reload();
}, 30000);
</script>
</body>
</html>
Setting Up Routes
The routing in Laravel 11 is beautifully simple. I added these to my web.php:
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\AnnouncementController;
Route::get('/', [AnnouncementController::class, 'index'])->name('home');
Route::post('/add-announcement', [AnnouncementController::class, 'store'])->name('add.announcement');
Route::delete('/clear-seen', [AnnouncementController::class, 'clearSeen'])->name('clear.seen');
Yeah, I used the Tailwind CDN. Sue me. For a demo project, it gets the job done without the build step complexity.
The Gotchas (And How I Fixed Them)
NativePHP Installation Nightmare
Before I even got to the fun part of building the announcement system, I hit a massive roadblock trying to get NativePHP working. I had this grand vision of turning my Laravel app into a native desktop application – you know, the whole “write once, run everywhere” dream.
The installation seemed straightforward enough:
composer require nativephp/laravel
php artisan native:install
But then came the nightmare. When I tried to start the app with php artisan native:serve, I got hit with a barrage of errors. Electron wouldn’t download properly, Node.js version conflicts, missing dependencies – it was like every possible thing that could go wrong, did go wrong.
After spending an entire afternoon (and I mean the ENTIRE afternoon) troubleshooting, I realized I was overcomplicating things. The core functionality I needed was just a web-based announcement system. Did I really need it to be a native app? Spoiler alert: I didn’t.
Sometimes the best solution is knowing when to pivot. I shelved the NativePHP idea and focused on building a solid web application first. Lesson learned: get your core functionality working before adding the bells and whistles.
File Permissions
Don’t forget to make sure Laravel can write to the storage/app directory. I ran into this on my production server and couldn’t figure out why announcements weren’t being marked as seen.
chmod -R 775 storage/
The Final Result
The finished product feels responsive and modern. Users see a clean list of announcements, can easily mark them as read, and get visual feedback for their actions. The backend is maintainable, and adding new announcements is as simple as editing a JSON file.
Total development time? About 4 hours, including the time I spent debugging that JSON syntax error. Not bad for a fully functional announcement system.