web push notifications
Some checks failed
linter / quality (push) Failing after 1m5s
tests / ci (8.3) (push) Failing after 48s
tests / ci (8.4) (push) Failing after 1m3s
tests / ci (8.5) (push) Failing after 1m2s

This commit is contained in:
Daan Meijer 2026-06-21 23:17:33 +02:00
parent 1e0782385b
commit 98dc8659ba
15 changed files with 646 additions and 16 deletions

View File

@ -63,3 +63,7 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VITE_VAPID_PUBLIC_KEY="${VAPID_PUBLIC_KEY}"

View File

@ -5,11 +5,6 @@
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
## Persistent Project Context (IMPORTANT)
- You MUST read, understand, and strictly follow the underlying business logic and feature requests documented in `IDEA.md` in every session.
- You MUST adhere to all style architecture, backend transaction, and integration decisions recorded in `DECISIONS.md`. Update `DECISIONS.md` when any major design decisions are made during a session.
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
@ -58,12 +53,6 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
- Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Activity Service
- The `app/Services/ActivityService.php` class is used to create system messages and activities.
- To create a system message, use the `createMessage` method. The `$user` parameter should be `null` to indicate a system message.
- The `createMutation` method can be used to create a mutation and its associated system message.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
@ -137,7 +126,6 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
- **Environment Isolation during Test Runs (IMPORTANT)**: The test runner environment (e.g. `vendor/bin/pest` or `php artisan test`) can be polluted by the main project's local `.env` file settings if run directly in the active shell. This pollution can override test-specific configurations defined in `phpunit.xml` and lead to unexpected failures, such as CSRF (`419 Page Expired`) errors or database connection issues. Always prefix test commands with `env -i PATH=$PATH HOME=$HOME TERM=$TERM` (e.g., `env -i PATH=$PATH HOME=$HOME TERM=$TERM vendor/bin/pest`) to enforce a clean, isolated environment run.
=== inertia-laravel/core rules ===

View File

@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class WebPushController extends Controller
{
public function store(Request $request)
{
$request->validate([
'endpoint' => 'required',
'keys.p256dh' => 'required',
'keys.auth' => 'required',
]);
$endpoint = $request->endpoint;
$token = $request->keys['auth'];
$key = $request->keys['p256dh'];
Auth::user()->updatePushSubscription($endpoint, $key, $token);
return response()->json(['success' => true]);
}
public function destroy(Request $request)
{
$request->validate([
'endpoint' => 'required',
]);
Auth::user()->deletePushSubscription($request->endpoint);
return response()->json(['success' => true]);
}
}

View File

@ -13,6 +13,7 @@ use Illuminate\Support\Carbon;
use Laravel\Fortify\Contracts\PasskeyUser;
use Laravel\Fortify\PasskeyAuthenticatable;
use Laravel\Fortify\TwoFactorAuthenticatable;
use NotificationChannels\WebPush\HasPushSubscriptions;
/**
* @property int $id
@ -32,7 +33,7 @@ use Laravel\Fortify\TwoFactorAuthenticatable;
class User extends Authenticatable implements PasskeyUser
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable;
use HasFactory, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable, HasPushSubscriptions;
public function dynamics()
{

View File

@ -0,0 +1,61 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use NotificationChannels\WebPush\WebPushChannel;
use NotificationChannels\WebPush\WebPushMessage;
class NewActivityNotification extends Notification
{
use Queueable;
public $activity;
/**
* Create a new notification instance.
*/
public function __construct($activity)
{
$this->activity = $activity;
}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return [WebPushChannel::class];
}
/**
* Get the web push representation of the notification.
*/
public function toWebPush(object $notifiable): WebPushMessage
{
return (new WebPushMessage)
->title('New Activity')
->icon('/apple-touch-icon.png')
->body($this->activity['content'])
->action('View', 'view')
->data(['url' => $this->activity['url']]);
}
/**
* Get the array representation of the notification.
*
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [
//
];
}
}

View File

@ -11,6 +11,8 @@ use App\Models\ReadCursor;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use App\Notifications\NewActivityNotification;
class ActivityService
{
/**
@ -54,6 +56,8 @@ class ActivityService
'subject_type' => $subject ? get_class($subject) : null,
]);
$this->notify($message);
return $message;
}
@ -70,6 +74,21 @@ class ActivityService
return $mutation;
}
public function notify(Message $message)
{
$dynamic = $message->chat->chatable;
if ($dynamic instanceof Dynamic) {
$participants = $dynamic->participants;
foreach ($participants as $participant) {
if ($message->user_id !== $participant->id) {
$participant->notify(new NewActivityNotification($message));
}
}
}
}
/**
* Retrieve all activities for a given entity.
*/

View File

@ -11,6 +11,7 @@
"require": {
"php": "^8.4",
"inertiajs/inertia-laravel": "^3.0",
"laravel-notification-channels/webpush": "^11.0",
"laravel/chisel": "^0.1.0",
"laravel/fortify": "^1.37.2",
"laravel/framework": "^13.7",

375
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "4f6fe33dc680e6446bd6318d5bdd9ec9",
"content-hash": "f4c79dcf0b7f9f54487404715d1085c1",
"packages": [
{
"name": "bacon/bacon-qr-code",
@ -1461,6 +1461,72 @@
},
"time": "2026-04-30T15:30:29+00:00"
},
{
"name": "laravel-notification-channels/webpush",
"version": "11.0.0",
"source": {
"type": "git",
"url": "https://github.com/laravel-notification-channels/webpush.git",
"reference": "85b577e64459a9df06a24062e2b300abbaa99fa9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel-notification-channels/webpush/zipball/85b577e64459a9df06a24062e2b300abbaa99fa9",
"reference": "85b577e64459a9df06a24062e2b300abbaa99fa9",
"shasum": ""
},
"require": {
"illuminate/notifications": "^12.0|^13.0",
"illuminate/support": "^12.0|^13.0",
"minishlink/web-push": "^10.0.1",
"php": "^8.2"
},
"require-dev": {
"larastan/larastan": "^3.1",
"laravel/pint": "^1.25",
"mockery/mockery": "^1.0",
"orchestra/testbench": "^9.2|^10.0|^11.0",
"phpunit/phpunit": "^11.5.3|^12.5.12|^13.1.11",
"rector/rector": "^2.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"NotificationChannels\\WebPush\\WebPushServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"NotificationChannels\\WebPush\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Cretu Eusebiu",
"email": "me@cretueusebiu.com",
"homepage": "http://cretueusebiu.com",
"role": "Developer"
},
{
"name": "Joost de Bruijn",
"email": "joost@aqualabs.nl",
"role": "Maintainer"
}
],
"description": "Web Push Notifications driver for Laravel.",
"homepage": "https://github.com/laravel-notification-channels/webpush",
"support": {
"issues": "https://github.com/laravel-notification-channels/webpush/issues",
"source": "https://github.com/laravel-notification-channels/webpush/tree/11.0.0"
},
"time": "2026-05-24T13:22:27+00:00"
},
{
"name": "laravel/chisel",
"version": "v0.1.1",
@ -2759,6 +2825,77 @@
],
"time": "2026-03-08T20:05:35+00:00"
},
{
"name": "minishlink/web-push",
"version": "v10.1.0",
"source": {
"type": "git",
"url": "https://github.com/web-push-libs/web-push-php.git",
"reference": "c922021b4ed1a61e6604d8dc33a2e0378b4382e3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/web-push-libs/web-push-php/zipball/c922021b4ed1a61e6604d8dc33a2e0378b4382e3",
"reference": "c922021b4ed1a61e6604d8dc33a2e0378b4382e3",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"ext-openssl": "*",
"guzzlehttp/guzzle": "^7.9.2",
"php": ">=8.2",
"psr/log": "^2.0|^3.0",
"spomky-labs/base64url": "^2.0.4",
"symfony/polyfill-php83": "^1.33",
"web-token/jwt-library": "^3.4.9|^4.0.6"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^v3.92.2",
"phpstan/phpstan": "^2.1.33",
"phpstan/phpstan-deprecation-rules": "^2.0",
"phpstan/phpstan-phpunit": "^2.0",
"phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^11.5.46|^12.5.2",
"symfony/polyfill-iconv": "^1.33"
},
"suggest": {
"ext-bcmath": "Optional for performance.",
"ext-gmp": "Optional for performance."
},
"type": "library",
"autoload": {
"psr-4": {
"Minishlink\\WebPush\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Louis Lagrange",
"email": "lagrange.louis@gmail.com",
"homepage": "https://github.com/Minishlink"
}
],
"description": "Web Push library for PHP",
"homepage": "https://github.com/web-push-libs/web-push-php",
"keywords": [
"Push API",
"WebPush",
"notifications",
"push",
"web"
],
"support": {
"issues": "https://github.com/web-push-libs/web-push-php/issues",
"source": "https://github.com/web-push-libs/web-push-php/tree/v10.1.0"
},
"time": "2026-05-28T09:37:37+00:00"
},
{
"name": "monolog/monolog",
"version": "3.10.0",
@ -5027,6 +5164,71 @@
],
"time": "2024-06-11T12:45:25+00:00"
},
{
"name": "spomky-labs/base64url",
"version": "v2.0.4",
"source": {
"type": "git",
"url": "https://github.com/Spomky-Labs/base64url.git",
"reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Spomky-Labs/base64url/zipball/7752ce931ec285da4ed1f4c5aa27e45e097be61d",
"reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d",
"shasum": ""
},
"require": {
"php": ">=7.1"
},
"require-dev": {
"phpstan/extension-installer": "^1.0",
"phpstan/phpstan": "^0.11|^0.12",
"phpstan/phpstan-beberlei-assert": "^0.11|^0.12",
"phpstan/phpstan-deprecation-rules": "^0.11|^0.12",
"phpstan/phpstan-phpunit": "^0.11|^0.12",
"phpstan/phpstan-strict-rules": "^0.11|^0.12"
},
"type": "library",
"autoload": {
"psr-4": {
"Base64Url\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Florent Morselli",
"homepage": "https://github.com/Spomky-Labs/base64url/contributors"
}
],
"description": "Base 64 URL Safe Encoding/Decoding PHP Library",
"homepage": "https://github.com/Spomky-Labs/base64url",
"keywords": [
"base64",
"rfc4648",
"safe",
"url"
],
"support": {
"issues": "https://github.com/Spomky-Labs/base64url/issues",
"source": "https://github.com/Spomky-Labs/base64url/tree/v2.0.4"
},
"funding": [
{
"url": "https://github.com/Spomky",
"type": "github"
},
{
"url": "https://www.patreon.com/FlorentMorselli",
"type": "patreon"
}
],
"time": "2020-11-03T09:10:25+00:00"
},
{
"name": "spomky-labs/cbor-php",
"version": "3.2.3",
@ -6702,6 +6904,86 @@
],
"time": "2026-04-10T16:19:22+00:00"
},
{
"name": "symfony/polyfill-php83",
"version": "v1.38.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php83.git",
"reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8",
"reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Php83\\": ""
},
"classmap": [
"Resources/stubs"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-27T06:51:48+00:00"
},
{
"name": "symfony/polyfill-php84",
"version": "v1.38.1",
@ -8475,6 +8757,95 @@
],
"time": "2026-05-31T15:00:08+00:00"
},
{
"name": "web-token/jwt-library",
"version": "4.1.7",
"source": {
"type": "git",
"url": "https://github.com/web-token/jwt-library.git",
"reference": "fbcbf2c276d04d8b056f5c2957815abd5dfb704d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/web-token/jwt-library/zipball/fbcbf2c276d04d8b056f5c2957815abd5dfb704d",
"reference": "fbcbf2c276d04d8b056f5c2957815abd5dfb704d",
"shasum": ""
},
"require": {
"brick/math": "^0.12|^0.13|^0.14|^0.15|^0.16|^0.17",
"php": ">=8.2",
"psr/clock": "^1.0",
"spomky-labs/pki-framework": "^1.2.1"
},
"conflict": {
"spomky-labs/jose": "*"
},
"suggest": {
"ext-bcmath": "GMP or BCMath is highly recommended to improve the library performance",
"ext-gmp": "GMP or BCMath is highly recommended to improve the library performance",
"ext-openssl": "For key management (creation, optimization, etc.) and some algorithms (AES, RSA, ECDSA, etc.)",
"ext-sodium": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys",
"paragonie/sodium_compat": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys",
"spomky-labs/aes-key-wrap": "For all Key Wrapping algorithms (AxxxKW, AxxxGCMKW, PBES2-HSxxx+AyyyKW...)",
"symfony/console": "Needed to use console commands",
"symfony/http-client": "To enable JKU/X5U support."
},
"type": "library",
"autoload": {
"psr-4": {
"Jose\\Component\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Florent Morselli",
"homepage": "https://github.com/Spomky"
},
{
"name": "All contributors",
"homepage": "https://github.com/web-token/jwt-framework/contributors"
}
],
"description": "JWT library",
"homepage": "https://github.com/web-token",
"keywords": [
"JOSE",
"JWE",
"JWK",
"JWKSet",
"JWS",
"Jot",
"RFC7515",
"RFC7516",
"RFC7517",
"RFC7518",
"RFC7519",
"RFC7520",
"bundle",
"jwa",
"jwt",
"symfony"
],
"support": {
"issues": "https://github.com/web-token/jwt-library/issues",
"source": "https://github.com/web-token/jwt-library/tree/4.1.7"
},
"funding": [
{
"url": "https://github.com/Spomky",
"type": "github"
},
{
"url": "https://www.patreon.com/FlorentMorselli",
"type": "patreon"
}
],
"time": "2026-06-06T18:12:39+00:00"
},
{
"name": "webmozart/assert",
"version": "2.4.0",
@ -12251,5 +12622,5 @@
"php": "^8.4"
},
"platform-dev": {},
"plugin-api-version": "2.6.0"
"plugin-api-version": "2.9.0"
}

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::connection(config('webpush.database_connection'))->create(config('webpush.table_name'), function (Blueprint $table) {
$table->bigIncrements('id');
$table->morphs('subscribable', 'push_subscriptions_subscribable_morph_idx');
$table->string('endpoint', 500)->unique();
$table->string('public_key')->nullable();
$table->string('auth_token')->nullable();
$table->string('content_encoding')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::connection(config('webpush.database_connection'))->dropIfExists(config('webpush.table_name'));
}
};

View File

@ -49,3 +49,7 @@ initializeTheme();
// This will listen for flash toast data from the server...
initializeFlashToast();
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}

View File

@ -0,0 +1,54 @@
import { ref } from 'vue';
export function usePushNotifications() {
const isSubscribed = ref(false);
async function subscribe() {
if (!('serviceWorker' in navigator)) {
return;
}
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(import.meta.env.VITE_VAPID_PUBLIC_KEY),
});
await axios.post('/subscriptions', subscription);
isSubscribed.value = true;
}
async function unsubscribe() {
if (!('serviceWorker' in navigator)) {
return;
}
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await axios.post('/subscriptions/delete', { endpoint: subscription.endpoint });
await subscription.unsubscribe();
}
isSubscribed.value = false;
}
function urlBase64ToUint8Array(base64String: string) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
return {
isSubscribed,
subscribe,
unsubscribe,
};
}

View File

@ -1,14 +1,25 @@
<script setup lang="ts">
import AppLayout from '@/layouts/app/AppSidebarLayout.vue';
import type { BreadcrumbItem } from '@/types';
import { usePushNotifications } from '@/composables/usePushNotifications';
const { breadcrumbs = [] } = defineProps<{
breadcrumbs?: BreadcrumbItem[];
}>();
const { isSubscribed, subscribe, unsubscribe } = usePushNotifications();
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<slot />
<div class="fixed bottom-4 right-4">
<button
@click="isSubscribed ? unsubscribe() : subscribe()"
class="rounded-full bg-blue-500 px-4 py-2 text-white"
>
{{ isSubscribed ? 'Unsubscribe' : 'Subscribe' }}
</button>
</div>
</AppLayout>
</template>

40
resources/js/sw.js Normal file
View File

@ -0,0 +1,40 @@
// This file is based on the one found in the laravel-notification-channels/webpush package.
// We need this to get the VAPID_PUBLIC_KEY from the .env file
const vapidPublicKey = import.meta.env.VITE_VAPID_PUBLIC_KEY;
self.addEventListener('push', (event) => {
const payload = event.data ? event.data.json() : {};
const options = {
body: payload.body,
icon: payload.icon,
badge: payload.badge,
data: {
url: payload.url,
},
};
event.waitUntil(self.registration.showNotification(payload.title, options));
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
});
if (vapidPublicKey) {
self.addEventListener('load', async () => {
if ('serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.register('/sw.js');
console.log('Service worker registered.', registration);
} catch (error) {
console.error('Error registering service worker:', error);
}
}
});
}

View File

@ -9,6 +9,9 @@ use Illuminate\Support\Facades\Route;
Route::inertia('/', 'Welcome')->name('home');
Route::post('subscriptions', [\App\Http\Controllers\WebPushController::class, 'store'])->name('subscriptions.store');
Route::post('subscriptions/delete', [\App\Http\Controllers\WebPushController::class, 'destroy'])->name('subscriptions.destroy');
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard');

View File

@ -12,7 +12,7 @@ export default defineConfig({
},
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.ts'],
input: ['resources/css/app.css', 'resources/js/app.ts', 'resources/js/sw.js'],
refresh: true,
fonts: [
bunny('Instrument Sans', {