NashTech Blog

How I Built a Desktop Announcement App Using Laravel 11 and NativePHP

Table of Contents

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.json for the actual announcements
  • storage/app/seen.json for 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.

Leave a Comment

Suggested Article

Discover more from NashTech Blog

Subscribe now to keep reading and get access to the full archive.

Continue reading