{"id":32115858,"url":"https://github.com/josbeir/cakephp-mercure","last_synced_at":"2025-12-13T06:38:41.113Z","repository":{"id":319544244,"uuid":"1078978789","full_name":"josbeir/cakephp-mercure","owner":"josbeir","description":"Push real-time updates to clients with Mercure 🚀 ","archived":false,"fork":false,"pushed_at":"2025-11-03T18:50:16.000Z","size":192,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2025-11-23T10:11:04.280Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":"","language":"PHP","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/josbeir.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE.md","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2025-10-18T20:33:23.000Z","updated_at":"2025-11-03T18:46:37.000Z","dependencies_parsed_at":"2025-10-19T12:25:50.966Z","dependency_job_id":"75ca1454-622d-4f26-84c3-028ac9c7ed4f","html_url":"https://github.com/josbeir/cakephp-mercure","commit_stats":null,"previous_names":["josbeir/cakephp-mercure"],"tags_count":4,"template":false,"template_full_name":null,"purl":"pkg:github/josbeir/cakephp-mercure","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/josbeir%2Fcakephp-mercure","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/josbeir%2Fcakephp-mercure/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/josbeir%2Fcakephp-mercure/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/josbeir%2Fcakephp-mercure/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/josbeir","download_url":"https://codeload.github.com/josbeir/cakephp-mercure/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/josbeir%2Fcakephp-mercure/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":27701742,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","status":"online","status_checked_at":"2025-12-13T02:00:09.769Z","response_time":147,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2025-10-20T16:00:50.723Z","updated_at":"2025-12-13T06:38:41.106Z","avatar_url":"https://github.com/josbeir.png","language":"PHP","funding_links":[],"categories":["Notifications and Real-time Communication","Plugins"],"sub_categories":["Notifications and Real-time Communication"],"readme":"[![PHPStan Level 8](https://img.shields.io/badge/PHPStan-level%208-brightgreen)](https://github.com/josbeir/cakephp-mercure)\n[![Build Status](https://github.com/josbeir/cakephp-mercure/actions/workflows/ci.yml/badge.svg)](https://github.com/josbeir/cakephp-mercure/actions)\n[![codecov](https://codecov.io/github/josbeir/cakephp-mercure/graph/badge.svg?token=4VGWJQTWH5)](https://codecov.io/github/josbeir/cakephp-mercure)\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)\n[![PHP Version](https://img.shields.io/badge/php-8.2%2B-blue.svg)](https://www.php.net/releases/8.2/en.php)\n[![Packagist Downloads](https://img.shields.io/packagist/dt/josbeir/cakephp-mercure)](https://packagist.org/packages/josbeir/cakephp-mercure)\n\n# CakePHP Mercure Plugin\n\nPush real-time updates to clients using the Mercure protocol.\n\n[![Mercure](https://mercure.rocks/static/logo.svg)](https://mercure.rocks/)\n\n## Table of Contents\n\n- [Overview](#overview)\n- [Installation](#installation)\n  - [Installing the Plugin](#installing-the-plugin)\n  - [Running a Mercure Hub](#running-a-mercure-hub)\n- [Configuration](#configuration)\n- [Basic Usage](#basic-usage)\n  - [Choosing Your Authorization Strategy](#choosing-your-authorization-strategy)\n  - [Publishing Updates](#publishing-updates)\n    - [Publishing JSON Data](#publishing-json-data)\n    - [Publishing Rendered Views](#publishing-rendered-views)\n  - [Subscribing to Updates](#subscribing-to-updates)\n- [Authorization](#authorization)\n  - [Publishing Private Updates](#publishing-private-updates)\n  - [Setting Authorization Cookies](#setting-authorization-cookies)\n    - [Using the Component](#using-the-component)\n    - [Setting Default Topics](#setting-default-topics)\n    - [Using the Facade classes](#using-the-facade-classes)\n- [Mercure Discovery](#mercure-discovery)\n  - [Using the Component](#using-the-component-1)\n  - [Using Middleware](#using-middleware)\n- [Advanced Configuration](#advanced-configuration)\n  - [JWT Token Strategies](#jwt-token-strategies)\n  - [HTTP Client Options](#http-client-options)\n  - [Cookie Configuration](#cookie-configuration)\n- [Testing](#testing)\n- [API Reference](#api-reference)\n  - [Publisher](#publisher)\n  - [MercureComponent](#mercurecomponent)\n  - [Authorization](#authorization-1)\n  - [MercureHelper](#mercurehelper)\n  - [Update](#update)\n  - [JsonUpdate](#jsonupdate)\n  - [ViewUpdate](#viewupdate)\n  - [MercureDiscoveryMiddleware](#mercurediscoverymiddleware)\n- [Contributing](#contributing)\n- [License](#license)\n\n## Overview\n\nThis plugin provides integration between CakePHP applications and the [Mercure protocol](https://mercure.rocks/), enabling real-time push capabilities for modern web applications.\n\nMercure is an open protocol built on top of Server-Sent Events (SSE) that allows you to:\n\n- Push updates from your server to clients in real-time\n- Create live-updating UIs without complex WebSocket infrastructure\n- Broadcast data changes to multiple connected users\n- Handle authorization for private updates\n- Automatically reconnect with missed update retrieval\n\nCommon use cases include live dashboards, collaborative editing, real-time notifications, and chat applications.\n\n## Installation\n\n### Installing the Plugin\n\n\u003e [!IMPORTANT]\n\u003e **Minimum Requirements:**\n\u003e - PHP 8.2 or higher\n\u003e - CakePHP 5.0.1 or higher\n\nInstall the plugin using Composer:\n\n```bash\ncomposer require josbeir/cakephp-mercure\n```\n\nLoad the plugin in your `Application.php`:\n\n```php\n// src/Application.php\npublic function bootstrap(): void\n{\n    parent::bootstrap();\n\n    $this-\u003eaddPlugin('Mercure');\n}\n```\n\nAlternatively, you can add it to `config/plugins.php`:\n\n```php\n// config/plugins.php\nreturn [\n    'Mercure' =\u003e [],\n];\n```\n\n### Running a Mercure Hub\n\nMercure requires a hub server to manage persistent SSE connections. Download the official hub from [Mercure.rocks](https://mercure.rocks/).\n\nFor development, you can run the hub using Docker:\n\n```bash\ndocker run -d \\\n    -e SERVER_NAME=:3000 \\\n    -e MERCURE_PUBLISHER_JWT_KEY='!ChangeThisMercureHubJWTSecretKey!' \\\n    -e MERCURE_SUBSCRIBER_JWT_KEY='!ChangeThisMercureHubJWTSecretKey!' \\\n    -p 3000:3000 \\\n    dunglas/mercure\n```\n\nIf you're using DDEV, you can install the Mercure add-on:\n\n```bash\nddev get Rindula/ddev-mercure\n```\n\nFor more information, see the [DDEV Mercure add-on](https://addons.ddev.com/addons/Rindula/ddev-mercure).\n\nThe hub will be available at `http://localhost:3000/.well-known/mercure`.\n\n\u003e [!TIP]\n\u003e **Using FrankenPHP?** You're good to go! FrankenPHP has Mercure built in—no separate hub needed. See the [FrankenPHP Mercure documentation](https://frankenphp.dev/docs/mercure/) for details.\n\n## Configuration\n\nThe plugin comes with sensible defaults and multiple configuration options.\n\n**Quick Setup (Environment Variables):**\n\nFor development, the fastest way to get started is using environment variables in your `.env` file:\n\n```env\nMERCURE_URL=http://localhost:3000/.well-known/mercure\nMERCURE_PUBLIC_URL=http://localhost:3000/.well-known/mercure\nMERCURE_JWT_SECRET=!ChangeThisMercureHubJWTSecretKey!\n```\n\n**Configuration Files:**\n\nThe plugin loads configuration in this order:\n\n1. **Plugin defaults** - `vendor/josbeir/cakephp-mercure/config/mercure.php` (loaded automatically)\n2. **Your overrides** - `config/app_mercure.php` (optional, loaded after plugin defaults)\n\nCreate `config/app_mercure.php` in your project to customize any settings. Your values will override the plugin defaults.\n\n**Cross-Subdomain Setup:**\n\n\u003e [!NOTE]\n\u003e If your Mercure hub runs on a different subdomain than your CakePHP application (e.g., `hub.example.com` vs `app.example.com`), you must configure the cookie domain:\n\u003e\n\u003e ```env\n\u003e # Allow cookie sharing across subdomains\n\u003e MERCURE_COOKIE_DOMAIN=.example.com\n\u003e ```\n\u003e\n\u003e This enables the authorization cookie to be accessible by both your application and the Mercure hub. Without this setting, authorization will fail for cross-subdomain requests.\n\nFor a complete list of available environment variables, see the plugin's `config/mercure.php` [file](https://github.com/josbeir/cakephp-mercure/blob/main/config/mercure.php).\n\n## Basic Usage\n\nThe plugin provides multiple integration points depending on your use case:\n\n- **Controllers**: Use the `MercureComponent` to centrally manage both authorization and subscriptions as topics\n- **Templates**: Use the `MercureHelper` to generate Mercure topic URLs for EventSource subscriptions in your views and templates.\n- **Services \u0026 Manual Control**: Use the `Publisher` facade to publish updates and the `Authorization` facade for direct response manipulation when you need lower-level control (e.g., outside controllers/views, such as in background jobs or custom middleware).\n\n\u003e [!TIP]\n\u003e **Note:** Facades (`Publisher`, `Authorization`) can be used in any context where a CakePHP component or helper does not fit, such as in queue jobs, commands, models, or other non-HTTP or background processing code. This makes them ideal for use outside of controllers and views.\n\n### Choosing Your Authorization Strategy\n\nPick the approach that best fits your workflow:\n\n| Scenario | Recommended Approach | Method to Use |\n|----------|---------------------|---------------|\n| **Authorize in controller, display URL in template** | `MercureComponent` + `MercureHelper` | `$this-\u003eMercure-\u003eauthorize()` in controller, `$this-\u003eMercure-\u003eurl($topics)` in template |\n| **Public topics (no authorization)** | `MercureHelper` | `$this-\u003eMercure-\u003eurl($topics)` |\n| **Manual response control** | `Authorization` facade | `Authorization::setCookie($response, $subscribe)` |\n\n### Publishing Updates\n\nUse the `Publisher` facade to send updates to the Mercure hub:\n\n```php\nuse Mercure\\Publisher;\nuse Mercure\\Update\\Update;\n\n// In a controller or service\n$update = new Update(\n    topics: 'https://example.com/books/1',\n    data: json_encode(['status' =\u003e 'OutOfStock'])\n);\n\nPublisher::publish($update);\n```\n\nThe `topics` parameter identifies the resource being updated. It should be a unique IRI (Internationalized Resource Identifier), typically the resource's URL.\n\nYou can publish to multiple topics simultaneously:\n\n```php\n$update = new Update(\n    topics: [\n        'https://example.com/books/1',\n        'https://example.com/notifications',\n    ],\n    data: json_encode(['message' =\u003e 'Book status changed'])\n);\n\nPublisher::publish($update);\n```\n\n\u003e [!TIP]\n\u003e **Using MercureComponent in Controllers:** If you're publishing from a controller, the `MercureComponent` provides convenient methods that eliminate the need to manually create Update objects or call the Publisher facade:\n\u003e\n\u003e ```php\n\u003e // In your controller\n\u003e public function initialize(): void\n\u003e {\n\u003e     parent::initialize();\n\u003e     $this-\u003eloadComponent('Mercure.Mercure');\n\u003e }\n\u003e\n\u003e public function update($id)\n\u003e {\n\u003e     $book = $this-\u003eBooks-\u003eget($id);\n\u003e     $book = $this-\u003eBooks-\u003epatchEntity($book, $this-\u003erequest-\u003egetData());\n\u003e     $this-\u003eBooks-\u003esave($book);\n\u003e\n\u003e     // Publish JSON directly - no need for Publisher facade\n\u003e     $this-\u003eMercure-\u003epublishJson(\n\u003e         topics: \"/books/{$id}\",\n\u003e         data: ['status' =\u003e $book-\u003estatus, 'title' =\u003e $book-\u003etitle]\n\u003e     );\n\u003e\n\u003e     // Or publish a rendered element\n\u003e     $this-\u003eMercure-\u003epublishView(\n\u003e         topics: \"/books/{$id}\",\n\u003e         element: 'Books/item',\n\u003e         data: ['book' =\u003e $book]\n\u003e     );\n\u003e }\n\u003e ```\n\u003e\n\u003e See the [MercureComponent API Reference](#mercurecomponent) for all available methods.\n\n#### Publishing JSON Data\n\nFor convenience when publishing JSON data, use the `JsonUpdate` class which automatically encodes arrays and objects to JSON:\n\n```php\nuse Mercure\\Publisher;\nuse Mercure\\Update\\JsonUpdate;\n\n// Simple array - no need to call json_encode()\n$update = JsonUpdate::create(\n    topics: 'https://example.com/books/1',\n    data: ['status' =\u003e 'OutOfStock', 'quantity' =\u003e 0]\n);\n\nPublisher::publish($update);\n\n// Or use the fluent builder pattern\n$update = (new JsonUpdate('https://example.com/books/1'))\n    -\u003edata(['status' =\u003e 'OutOfStock', 'quantity' =\u003e 0])\n    -\u003ebuild();\n\nPublisher::publish($update);\n```\n\nYou can customize JSON encoding options:\n\n```php\n// With custom JSON encoding options\n$update = JsonUpdate::create(\n    topics: 'https://example.com/books/1',\n    data: ['title' =\u003e 'Book \u0026 Title', 'price' =\u003e 19.99],\n    jsonOptions: JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE\n);\n\n// Or using the fluent builder\n$update = (new JsonUpdate('https://example.com/books/1'))\n    -\u003edata(['title' =\u003e 'Book \u0026 Title', 'price' =\u003e 19.99])\n    -\u003ejsonOptions(JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)\n    -\u003ebuild();\n\nPublisher::publish($update);\n```\n\nFor private updates and event metadata:\n\n```php\n$update = JsonUpdate::create(\n    topics: 'https://example.com/users/123/notifications',\n    data: ['message' =\u003e 'New notification', 'unread' =\u003e 5],\n    private: true\n);\n\n// Or chain multiple options with the fluent builder\n$update = (new JsonUpdate('https://example.com/books/1'))\n    -\u003edata(['title' =\u003e 'New Book', 'price' =\u003e 29.99])\n    -\u003eprivate()\n    -\u003eid(Text::uuid())\n    -\u003etype('book.created')\n    -\u003eretry(5000)\n    -\u003ebuild();\n\nPublisher::publish($update);\n```\n\n#### Publishing Rendered Views\n\nUse the `ViewUpdate` class to automatically render CakePHP views or elements and publish the rendered HTML.\n\n\u003e [!NOTE]\n\u003e This is especially handy when using JavaScript frameworks like [htmx](https://htmx.org/) (for instance, using the [htmx-sse extension](https://htmx.org/extensions/sse/)), [Hotwire](https://hotwired.dev/) (with [Turbo Streams](https://turbo.hotwired.dev/handbook/streams)), or similar reactive libraries, which can consume and swap HTML fragments received over Mercure for seamless real-time UI updates.\n\n```php\nuse Mercure\\Publisher;\nuse Mercure\\Update\\ViewUpdate;\n\n// Render an element\n$update = ViewUpdate::create(\n    topics: 'https://example.com/books/1',\n    element: 'Books/item',\n    viewVars: ['book' =\u003e $book]\n);\n\n// Or use the fluent builder pattern\n$update = (new ViewUpdate('https://example.com/books/1'))\n    -\u003eelement('Books/item')\n    -\u003eviewVars(['book' =\u003e $book])\n    -\u003ebuild();\n\nPublisher::publish($update);\n```\n\nYou can also render full templates:\n\n```php\n// Render a template\n$update = ViewUpdate::create(\n    topics: 'https://example.com/notifications',\n    template: 'Notifications/item',\n    viewVars: ['notification' =\u003e $notification]\n);\n\n// Or with the fluent builder - add view options too\n$update = (new ViewUpdate('https://example.com/notifications'))\n    -\u003etemplate('Notifications/item')\n    -\u003eviewVars(['notification' =\u003e $notification])\n    -\u003eviewOptions(['key' =\u003e 'value'])\n    -\u003ebuild();\n\nPublisher::publish($update);\n```\n\nFor private updates with event metadata:\n\n```php\n$update = ViewUpdate::create(\n    topics: 'https://example.com/users/123/messages',\n    element: 'Messages/item',\n    viewVars: ['message' =\u003e $message],\n    private: true\n);\n\n// Or chain all options with the fluent builder\n$update = (new ViewUpdate('https://example.com/users/123/messages'))\n    -\u003eelement('Messages/item')\n    -\u003eviewVars(['message' =\u003e $message])\n    -\u003eprivate()\n    -\u003eid('msg-456')\n    -\u003etype('message.new')\n    -\u003ebuild();\n\nPublisher::publish($update);\n```\n\n### Subscribing to Updates\n\nThe plugin provides a View Helper to generate Mercure URLs in your templates.\n\nFirst, load the helper in `AppView`:\n\n```php\n// In src/View/AppView.php\npublic function initialize(): void\n{\n    parent::initialize();\n    $this-\u003eloadHelper('Mercure.Mercure');\n}\n```\n\nThen subscribe to updates from your templates:\n\n```php\n// In your template\n\u003cdiv id=\"book-status\"\u003eAvailable\u003c/div\u003e\n\n\u003cscript\u003e\n// For public topics (no authorization needed)\nconst eventSource = new EventSource('\u003c?= $this-\u003eMercure-\u003eurl(['https://example.com/books/1']) ?\u003e');\n\neventSource.onmessage = (event) =\u003e {\n    const data = JSON.parse(event.data);\n    document.getElementById('book-status').textContent = data.status;\n};\n\u003c/script\u003e\n```\n\nSubscribe to multiple topics:\n\n```php\n\u003cscript\u003e\n// Subscribe to multiple topics\nconst url = '\u003c?= $this-\u003eMercure-\u003eurl([\n    'https://example.com/books/1',\n    'https://example.com/notifications'\n]) ?\u003e';\n\nconst eventSource = new EventSource(url);\neventSource.onmessage = (event) =\u003e {\n    console.log('Update received:', event.data);\n};\n\u003c/script\u003e\n```\n\nIf you need to access the Mercure URL from an external JavaScript file, store it in a data element:\n\n```php\n\u003cscript type=\"application/json\" id=\"mercure-url\"\u003e\n\u003c?= json_encode(\n    $this-\u003eMercure-\u003eurl(['https://example.com/books/1']),\n    JSON_UNESCAPED_SLASHES | JSON_HEX_TAG\n) ?\u003e\n\u003c/script\u003e\n```\n\nThen retrieve it from your JavaScript:\n\n```javascript\nconst url = JSON.parse(document.getElementById('mercure-url').textContent);\nconst eventSource = new EventSource(url);\neventSource.onmessage = (event) =\u003e {\n    console.log('Update received:', event.data);\n};\n```\n\n## Authorization\n\n### Publishing Private Updates\n\nMark updates as private to restrict access to authorized subscribers:\n\n```php\n$update = new Update(\n    topics: 'https://example.com/users/123/messages',\n    data: json_encode(['text' =\u003e 'Private message']),\n    private: true\n);\n\nPublisher::publish($update);\n```\n\nPrivate updates are only delivered to subscribers with valid JWT tokens containing matching topic selectors.\n\n### Setting Authorization Cookies\n\n#### Using the Component\n\nFor centralized authorization logic, use the `MercureComponent` in controllers. Topics added via the component are automatically available in your views:\n\n```php\nclass BooksController extends AppController\n{\n    public function initialize(): void\n    {\n        parent::initialize();\n        $this-\u003eloadComponent('Mercure.Mercure');\n    }\n\n    public function view($id)\n    {\n        $book = $this-\u003eBooks-\u003eget($id);\n        $userId = $this-\u003erequest-\u003egetAttribute('identity')-\u003eid;\n\n        // Using builder pattern\n        $this-\u003eMercure\n            -\u003eaddTopic('https://example.com/books/123') // You can also set this using MercureHelper or using the defaultTopics option.\n            -\u003eaddSubscribe(\"https://example.com/books/{$id}\")\n            -\u003eaddSubscribe(\"https://example.com/notifications/{$id}\")\n            -\u003eauthorize() // This sets the actual JWT cookie.\n\n        // Or direct authorization\n        $this-\u003eMercure-\u003eauthorize(\n            subscribe: [\"https://example.com/books/{$id}\"],\n            additionalClaims: ['sub' =\u003e $userId] // Optional\n        );\n\n        $this-\u003eset('book', $book);\n    }\n\n    public function logout()\n    {\n        // Clear authorization on logout\n        $this-\u003eMercure-\u003eclearAuthorization(); // Removes the JWT cookie.\n\n        return $this-\u003eredirect(['action' =\u003e 'login']);\n    }\n}\n```\n\nThe component provides separation of concerns (authorization in controller, URLs in template). You can also enable automatic discovery headers:\n\n```php\n// In AppController\n$this-\u003eloadComponent('Mercure.Mercure', [\n    'autoDiscover' =\u003e true,  // Automatically add discovery headers\n]);\n```\n\n#### Setting Default Topics\n\nYou can configure default topics that will be automatically merged with any topics you provide to `url()`. This is useful when you want certain topics (like notifications or global alerts) to be included in every subscription:\n\n```php\n// In your `AppView` using the helper\npublic function initialize(): void\n{\n    parent::initialize();\n\n    // Load helper with default topics\n    $this-\u003eloadHelper('Mercure', [\n        'defaultTopics' =\u003e [\n            'https://example.com/notifications',\n            'https://example.com/alerts'\n        ]\n    ]);\n}\n```\n\nYou can also set default topics using the `MercureComponent` in your controller:\n\n```php\n// In your controller using the component\npublic function initialize(): void\n{\n    parent::initialize();\n    $this-\u003eloadComponent('Mercure.Mercure', [\n        'defaultTopics' =\u003e [\n            'https://example.com/notifications',\n            'https://example.com/alerts'\n        ]\n    ]);\n}\n```\n\nNow every call to `MercureHelper::url()` will automatically include these default topics:\n\n```php\n// In your template\n\u003cscript\u003e\n// This will subscribe to: /notifications, /alerts, AND /books/123\nconst url = '\u003c?= $this-\u003eMercure-\u003eurl(['/books/123']) ?\u003e';\nconst eventSource = new EventSource(url, { withCredentials: true });\n\u003c/script\u003e\n\n// You can also add topics dynamically:\n$this-\u003eMercure-\u003eaddTopic('/user/' . $userId . '/messages');\n$this-\u003eMercure-\u003eaddTopics(['/books/456', '/comments/789']);\n\n// These will be merged with configured defaults\nconst url = '\u003c?= $this-\u003eMercure-\u003eurl(['/books/123']) ?\u003e';\n// Result includes: /notifications, /alerts, /user/{id}/messages, /books/456, /comments/789, AND /books/123\n```\n\n#### Using the Facade classes\n\nFor more control or when not using controllers, you can use the `Authorization` facade directly:\n\n```php\nuse Mercure\\Authorization;\n\npublic function view($id)\n{\n    $book = $this-\u003eBooks-\u003eget($id);\n\n    // Allow this user to subscribe to updates for this book\n    $response = Authorization::setCookie(\n        $this-\u003eresponse,\n        subscribe: [\"https://example.com/books/{$id}\"]\n    );\n\n    $this-\u003eset('book', $book);\n    return $response;\n}\n```\n\nThe cookie must be set before establishing the EventSource connection. The Mercure hub and your CakePHP application should share the same domain (different subdomains are allowed).\n\n## Mercure Discovery\n\nThe Mercure protocol supports automatic hub discovery via HTTP Link headers. This allows clients to discover the hub URL without hardcoding it, making your application more flexible and following the Mercure specification.\n\n### Using the Component\n\nAdd the discovery header from your controller using the `MercureComponent`:\n\n```php\n// In your controller action\n$this-\u003eMercure-\u003ediscover();\n```\n\nThis adds a `Link` header to the response:\n\n```\nLink: \u003chttps://mercure.example.com/.well-known/mercure\u003e; rel=\"mercure\"\n```\n\nClients can then discover the hub URL from the response headers:\n\n```javascript\nfetch('/api/resource')\n    .then(response =\u003e {\n        const linkHeader = response.headers.get('Link');\n        // Parse the Link header to extract the Mercure hub URL\n        const match = linkHeader.match(/\u003c([^\u003e]+)\u003e;\\s*rel=\"mercure\"/);\n        if (match) {\n            const hubUrl = match[1];\n            const eventSource = new EventSource(hubUrl + '?topic=/api/resource');\n        }\n    });\n```\n\n### Discovery with Topics and Attributes\n\nThe `discover()` method supports optional parameters that align with the Mercure specification:\n\n```php\n// Add discovery header with optional link attributes\n$this-\u003eMercure-\u003ediscover(\n    lastEventId: '123',\n    contentType: 'application/json',\n    keySet: 'https://example.com/.well-known/jwks.json'\n);\n```\n\n**Include subscription topics in discovery:**\n\nWhen you want the `rel=\"self\"` Link header to include the topics the user is authorized for, enable `discoverWithTopics`:\n\n```php\n// Option 1: Enable per-call\n$this-\u003eMercure\n    -\u003eauthorize(['/books/123', '/notifications/*'])\n    -\u003ediscover(includeTopics: true);\n\n// Option 2: Enable in component config\n$this-\u003eloadComponent('Mercure.Mercure', [\n    'discoverWithTopics' =\u003e true\n]);\n\n// Then in your action:\n$this-\u003eMercure\n    -\u003eauthorize(['/books/123'])\n    -\u003ediscover(); // Automatically includes topics in rel=\"self\"\n```\n\nThis generates both headers:\n\n```\nLink: \u003chttps://hub.example.com/.well-known/mercure?topic=%2Fbooks%2F123\u003e; rel=\"self\"\nLink: \u003chttps://hub.example.com/.well-known/mercure\u003e; rel=\"mercure\"\n```\n\nThe `rel=\"self\"` header provides a ready-to-use subscription URL that clients can connect to directly.\n\n### Using Middleware\n\nThis is an alternative approach to add the discovery header automatically to all responses by using middleware:\n\n```php\n// In src/Application.php\nuse Mercure\\Http\\Middleware\\MercureDiscoveryMiddleware;\n\npublic function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue\n{\n    $middlewareQueue\n        // ... other middleware\n        -\u003eadd(new MercureDiscoveryMiddleware());\n\n    return $middlewareQueue;\n}\n```\n\nThe middleware automatically adds the `Link` header with `rel=\"mercure\"` to all responses, making the hub URL discoverable by any client.\n\n\u003e [!TIP]\n\u003e The discovery header uses the `public_url` configuration (or falls back to `url` if not set), ensuring clients always receive the correct publicly-accessible hub URL.\n\n## Advanced Configuration\n\n### JWT Token Strategies\n\nThe plugin supports multiple JWT generation strategies:\n\n**1. Secret-based (default):**\n\n```php\n'jwt' =\u003e [\n    'secret' =\u003e env('MERCURE_JWT_SECRET'),\n    'algorithm' =\u003e 'HS256',\n    'publish' =\u003e ['*'],\n    'subscribe' =\u003e ['*'],\n]\n```\n\n**2. Static token:**\n\n```php\n'jwt' =\u003e [\n    'value' =\u003e env('MERCURE_JWT_TOKEN'),\n]\n```\n\n**3. Custom provider:**\n\n```php\n'jwt' =\u003e [\n    'provider' =\u003e \\App\\Mercure\\CustomTokenProvider::class,\n]\n```\n\nImplement `Mercure\\Jwt\\TokenProviderInterface`:\n\n```php\nnamespace App\\Mercure;\n\nuse Mercure\\Jwt\\TokenProviderInterface;\n\nclass CustomTokenProvider implements TokenProviderInterface\n{\n    public function getJwt(): string\n    {\n        // Generate and return JWT token\n        return $this-\u003egenerateToken();\n    }\n}\n```\n\n**4. Custom factory:**\n\n```php\n'jwt' =\u003e [\n    'factory' =\u003e \\App\\Mercure\\CustomTokenFactory::class,\n    'secret' =\u003e env('MERCURE_JWT_SECRET'),\n    'publish' =\u003e ['*'],\n]\n```\n\nImplement `Mercure\\Jwt\\TokenFactoryInterface`:\n\n```php\nnamespace App\\Mercure;\n\nuse Mercure\\Jwt\\TokenFactoryInterface;\n\nclass CustomTokenFactory implements TokenFactoryInterface\n{\n    public function __construct(\n        private string $secret,\n        private string $algorithm\n    ) {}\n\n    public function create(array $subscribe = [], array $publish = [], array $additionalClaims = []): string\n    {\n        // Create and return JWT token\n    }\n}\n```\n\n### HTTP Client Options\n\nConfigure the HTTP client used to communicate with the Mercure hub:\n\n```php\n'http_client' =\u003e [\n    'timeout' =\u003e 30,\n    'ssl_verify_peer' =\u003e false, // For local development only\n]\n```\n\n### Cookie Configuration\n\nThe authorization cookie contains a JWT token that authenticates subscribers to private topics. JWT expiry is automatically calculated based on cookie lifetime settings.\n\n```php\n'cookie' =\u003e [\n    'name' =\u003e 'mercureAuthorization',\n\n    // Lifetime in seconds (0 for session cookie)\n    'lifetime' =\u003e 3600,  // 1 hour\n\n    // Or use explicit expiry datetime\n    // 'expires' =\u003e '+1 hour',\n\n    // Omit both to use PHP's session.cookie_lifetime setting\n\n    'domain' =\u003e '.example.com',\n    'path' =\u003e '/',\n    'secure' =\u003e true,      // HTTPS only (recommended)\n    'httponly' =\u003e true,    // Prevents XSS token theft\n    'samesite' =\u003e 'strict', // CSRF protection\n]\n```\n\n**JWT Expiry Management:**\n\nThe plugin automatically sets the JWT `exp` claim based on cookie lifetime, following this priority:\n\n1. `additionalClaims['exp']` - Per-request override\n2. `cookie.expires` - Explicit datetime (`'+1 hour'`, etc.)\n3. `cookie.lifetime` - Seconds (`3600` for 1 hour, `0` for session)\n4. `ini_get('session.cookie_lifetime')` - PHP session setting\n5. Default: +1 hour\n\nSession cookies (`lifetime: 0`) automatically get a 1-hour JWT expiry for security.\n\n**Security Notes:**\n\n- `httponly: true` (default) prevents JavaScript access while still allowing EventSource connections\n- `samesite: 'strict'` (default) provides CSRF protection\n- `secure: true` requires HTTPS (recommended for production)\n- JWT tokens always expire - no infinite authorization\n\nFor more details, see the [CakePHP Cookie documentation](https://book.cakephp.org/5/en/controllers/request-response.html#setting-cookies).\n\n## Testing\n\nFor testing, mock the Publisher service to avoid actual HTTP calls:\n\n```php\nuse Mercure\\Publisher;\nuse Mercure\\Service\\PublisherInterface;\nuse Mercure\\TestSuite\\MockPublisher;\n\n// In your test\npublic function testPublishing(): void\n{\n    // Se the mock publisher\n    Publisher::setInstance(new MockPublisher());\n\n    // Test your code that publishes updates\n    $this-\u003eMyService-\u003edoSomething();\n\n    // Clean up\n    Publisher::clear();\n}\n```\n\nSimilarly for Authorization:\n\n```php\nuse Mercure\\Authorization;\nuse Mercure\\Service\\AuthorizationInterface;\n\npublic function testAuthorization(): void\n{\n    $mockAuth = $this-\u003ecreateMock(AuthorizationInterface::class);\n    Authorization::setInstance($mockAuth);\n\n    // Your tests here\n\n    Authorization::clear();\n}\n```\n\n## API Reference\n\n### Publisher\n\n| Method | Returns | Description |\n|--------|---------|-------------|\n| `publish(Update $update)` | `bool` | Publish an update to the hub |\n| `setInstance(PublisherInterface $publisher)` | `void` | Set custom instance (for testing) |\n| `clear()` | `void` | Clear singleton instance |\n\n### MercureComponent\n\nController component for centralized authorization with separation of concerns and automatic dependency injection.\n\n**Loading the Component:**\n\n```php\npublic function initialize(): void\n{\n    parent::initialize();\n    $this-\u003eloadComponent('Mercure.Mercure', [\n        'autoDiscover' =\u003e true,      // Optional: auto-add discovery headers\n        'discoverWithTopics' =\u003e false, // Optional: include topics in rel=\"self\"\n        'defaultTopics' =\u003e [         // Optional: topics available in all views\n            '/notifications',\n            '/global/alerts'\n        ]\n    ]);\n}\n```\n\n**Methods:**\n\n| Method | Returns | Description |\n|--------|---------|-------------|\n| `addTopic(string $topic)` | `$this` | Add a topic for the view to subscribe to |\n| `addTopics(array $topics)` | `$this` | Add multiple topics for the view |\n| `getTopics()` | `array` | Get all topics added in the component |\n| `resetTopics()` | `$this` | Reset all accumulated topics |\n| `addSubscribe(string $topic, array $additionalClaims = [])` | `$this` | Add a topic to authorize with optional JWT claims |\n| `addSubscribes(array $topics, array $additionalClaims = [])` | `$this` | Add multiple topics to authorize with optional JWT claims |\n| `getSubscribe()` | `array` | Get accumulated subscribe topics |\n| `getAdditionalClaims()` | `array` | Get accumulated JWT claims |\n| `resetSubscribe()` | `$this` | Reset accumulated subscribe topics |\n| `resetAdditionalClaims()` | `$this` | Reset accumulated JWT claims |\n| `authorize(array $subscribe = [], array $additionalClaims = [])` | `$this` | Set authorization cookie (merges with accumulated state, then resets) |\n| `clearAuthorization()` | `$this` | Clear authorization cookie |\n| `discover(?bool $includeTopics, ?string $lastEventId, ?string $contentType, ?string $keySet)` | `$this` | Add Mercure discovery Link headers with optional attributes and topics |\n| `publish(Update $update)` | `bool` | Publish an update to the Mercure hub |\n| `publishJson(string\\|array $topics, mixed $data, ...)` | `bool` | Publish JSON data (auto-encodes) |\n| `publishSimple(string\\|array $topics, string $data, ...)` | `bool` | Publish simple string data (no encoding) |\n| `publishView(string\\|array $topics, ?string $template, ?string $element, array $data, ...)` | `bool` | Publish rendered view/element |\n| `getCookieName()` | `string` | Get the cookie name |\n\n**Topic Management:**\n\nTopics added in the controller are automatically available in `MercureHelper` in your views:\n\n```php\n// In controller\npublic function view($id)\n{\n    $book = $this-\u003eBooks-\u003eget($id);\n\n    // Add topics that will be available in the view\n    $this-\u003eMercure\n        -\u003eaddTopic(\"/books/{$id}\")\n        -\u003eaddTopic(\"/user/{$userId}/updates\")\n        -\u003eauthorize([\"/books/{$id}\"]);\n\n    $this-\u003eset('book', $book);\n}\n\n// In template - topics are automatically included\nconst url = '\u003c?= $this-\u003eMercure-\u003eurl() ?\u003e';\n// Subscribes to: /books/123 and /user/456/updates (from component)\n```\n\n**Authorization Builder Pattern:**\n\nBuild up authorization topics and claims fluently, then call `authorize()`:\n\n```php\n// Build up gradually with claims\n$this-\u003eMercure\n    -\u003eaddSubscribe('/books/123', ['sub' =\u003e $userId])\n    -\u003eaddSubscribe('/notifications/*', ['role' =\u003e 'admin'])\n    -\u003eauthorize()\n    -\u003ediscover();\n\n// Add multiple at once\n$this-\u003eMercure-\u003eaddSubscribes(\n    ['/books/123', '/notifications/*'],\n    ['sub' =\u003e $userId, 'role' =\u003e 'admin']\n);\n\n// Mix builder and direct parameters\n$this-\u003eMercure\n    -\u003eaddSubscribe('/books/123')\n    -\u003eauthorize(['/notifications/*'], ['sub' =\u003e $userId]);\n\n// Chain with topic management\n$this-\u003eMercure\n    -\u003eaddTopic('/books/123')                          // For EventSource\n    -\u003eaddSubscribe('/books/123', ['sub' =\u003e $userId])  // For authorization\n    -\u003eauthorize()\n    -\u003ediscover();\n```\n\nClaims accumulate across multiple `addSubscribe()` calls. The `authorize()` method automatically resets accumulated state after setting the cookie.\n\n**Publishing convenience methods** make it easy to publish updates directly from controllers:\n\n```php\n// Publish JSON data\n$this-\u003eMercure-\u003epublishJson(\n    topics: '/books/123',\n    data: ['status' =\u003e 'updated', 'title' =\u003e $book-\u003etitle]\n);\n\n// Publish rendered element\n$this-\u003eMercure-\u003epublishView(\n    topics: '/books/123',\n    element: 'Books/item',\n    data: ['book' =\u003e $book]\n);\n\n// Publish rendered template with layout\n$this-\u003eMercure-\u003epublishView(\n    topics: '/notifications',\n    template: 'Notifications/item',\n    layout: 'ajax',\n    data: ['notification' =\u003e $notification]\n);\n\n// For advanced use cases, publish an Update object directly\n$update = new Update('/books/123', json_encode(['data' =\u003e 'value']));\n$this-\u003eMercure-\u003epublish($update);\n```\n\n### Authorization\n\nStatic facade for direct authorization management (alternative to component).\n\n| Method | Returns | Description |\n|--------|---------|-------------|\n| `setCookie(Response $response, array $subscribe, array $additionalClaims)` | `Response` | Set authorization cookie |\n| `clearCookie(Response $response)` | `Response` | Clear authorization cookie |\n| `addDiscoveryHeader(Response $response)` | `Response` | Add Mercure discovery Link header |\n| `getCookieName()` | `string` | Get the cookie name |\n\n### MercureHelper\n\n| Method | Returns | Description |\n|--------|---------|-------------|\n| `url(array\\|string\\|null $topics, array $subscribe, array $additionalClaims)` | `string` | Get hub URL **and optionally authorize** (only sets cookie when `$subscribe` is provided). Merges with default topics if configured. |\n| `addTopic(string $topic)` | `$this` | Add a single topic to default topics (fluent interface) |\n| `addTopics(array $topics)` | `$this` | Add multiple topics to default topics (fluent interface) |\n\n**Configuration Options:**\n\n| Option | Type | Default | Description |\n|--------|------|---------|-------------|\n| `defaultTopics` | `array` | `[]` | Topics to automatically merge with every subscription (read-only, not mutated by `addTopic()`/`addTopics()`) |\n\n### Update\n\nBase class for Mercure updates. For most use cases, consider using `JsonUpdate` or `ViewUpdate` instead.\n\n**Constructor:**\n\n```php\nnew Update(\n    string|array $topics,\n    string $data,\n    bool $private = false,\n    ?string $id = null,\n    ?string $type = null,\n    ?int $retry = null\n)\n```\n\n**Constructor Parameters:**\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| `$topics` | `string\\|array` | Topic IRI(s) for the update |\n| `$data` | `string` | Update content (typically JSON) |\n| `$private` | `bool` | Whether the update requires authorization |\n| `$id` | `?string` | Optional SSE event ID |\n| `$type` | `?string` | Optional SSE event type |\n| `$retry` | `?int` | Optional reconnection time in milliseconds |\n\n**Methods:**\n\n| Method | Returns | Description |\n|--------|---------|-------------|\n| `getTopics()` | `array` | Get topics |\n| `getData()` | `string` | Get data |\n| `isPrivate()` | `bool` | Check if private |\n| `getId()` | `?string` | Get event ID |\n| `getType()` | `?string` | Get event type |\n| `getRetry()` | `?int` | Get retry value |\n\n### JsonUpdate\n\nSpecialized Update class that automatically encodes data to JSON. Supports both static factory method and fluent builder pattern.\n\n**Fluent Builder Pattern (Recommended):**\n\n```php\nuse Mercure\\Update\\JsonUpdate;\n\n// Basic usage\n$update = (new JsonUpdate('/books/1'))\n    -\u003edata(['status' =\u003e 'OutOfStock', 'quantity' =\u003e 0])\n    -\u003ebuild();\n\n// With all options\n$update = (new JsonUpdate('/books/1'))\n    -\u003edata(['title' =\u003e 'Book', 'price' =\u003e 29.99])\n    -\u003ejsonOptions(JSON_UNESCAPED_UNICODE)\n    -\u003eprivate()\n    -\u003eid(Text::uuid())\n    -\u003etype('book.updated')\n    -\u003eretry(5000)\n    -\u003ebuild();\n```\n\n**Builder Methods:**\n\n| Method | Parameter | Returns | Description |\n|--------|-----------|---------|-------------|\n| `data(mixed $data)` | Data to encode | `$this` | Set data to encode as JSON |\n| `jsonOptions(int $options)` | JSON options | `$this` | Set JSON encoding options |\n| `private(bool $private = true)` | Private flag | `$this` | Mark as private update |\n| `id(string $id)` | Event ID | `$this` | Set SSE event ID |\n| `type(string $type)` | Event type | `$this` | Set SSE event type |\n| `retry(int $retry)` | Retry delay (ms) | `$this` | Set retry delay |\n| `build()` | - | `Update` | Build and return Update |\n\n**Static Factory Method:**\n\n```php\nJsonUpdate::create(\n    string|array $topics,\n    mixed $data,\n    bool $private = false,\n    ?string $id = null,\n    ?string $type = null,\n    ?int $retry = null,\n    int $jsonOptions = JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR\n): Update\n```\n\n### ViewUpdate\n\nSpecialized Update class that automatically renders CakePHP views or elements. Supports both static factory method and fluent builder pattern.\n\n**Fluent Builder Pattern (Recommended):**\n\n```php\nuse Mercure\\Update\\ViewUpdate;\n\n// Render element\n$update = (new ViewUpdate('/books/1'))\n    -\u003eelement('Books/item')\n    -\u003eviewVars(['book' =\u003e $book])\n    -\u003ebuild();\n\n// Render template with all options\n$update = (new ViewUpdate('/notifications'))\n    -\u003etemplate('Notifications/item')\n    -\u003eviewVars(['notification' =\u003e $notification])\n    -\u003elayout('ajax')\n    -\u003eviewOptions(['key' =\u003e 'value'])\n    -\u003eprivate()\n    -\u003eid('notif-123')\n    -\u003etype('notification.new')\n    -\u003ebuild();\n```\n\n**Builder Methods:**\n\n| Method | Parameter | Returns | Description |\n|--------|-----------|---------|-------------|\n| `template(string $template)` | Template name | `$this` | Set template to render |\n| `element(string $element)` | Element name | `$this` | Set element to render |\n| `viewVars(array $viewVars)` | View variables | `$this` | Set view variables |\n| `set(string $key, mixed $value)` | Key, value | `$this` | Set single view variable |\n| `layout(?string $layout)` | Layout name | `$this` | Set layout (null to disable) |\n| `viewOptions(array $options)` | ViewBuilder options | `$this` | Set ViewBuilder options |\n| `private(bool $private = true)` | Private flag | `$this` | Mark as private update |\n| `id(string $id)` | Event ID | `$this` | Set SSE event ID |\n| `type(string $type)` | Event type | `$this` | Set SSE event type |\n| `retry(int $retry)` | Retry delay (ms) | `$this` | Set retry delay |\n| `build()` | - | `Update` | Build and return Update |\n\n**Static Factory Method:**\n\n```php\nViewUpdate::create(\n    string|array $topics,\n    ?string $template = null,\n    ?string $element = null,\n    array $viewVars = [],\n    ?string $layout = null,\n    bool $private = false,\n    ?string $id = null,\n    ?string $type = null,\n    ?int $retry = null,\n    array $viewOptions = []\n): Update\n```\n\n**Parameters:**\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| `$topics` | `string\\|array` | Topic IRI(s) for the update |\n| `$template` | `?string` | Template to render (e.g., 'Books/view') |\n| `$element` | `?string` | Element to render (e.g., 'Books/item') |\n| `$data` | `array` | View variables to pass to the template/element |\n| `$layout` | `?string` | Layout to use (null for no layout) |\n| `$private` | `bool` | Whether this is a private update |\n| `$id` | `?string` | Optional SSE event ID |\n| `$type` | `?string` | Optional SSE event type |\n| `$retry` | `?int` | Optional reconnection time in milliseconds |\n\n\u003e [!NOTE]\n\u003e Either `template` or `element` must be specified, but not both.\n\n**Example:**\n\n```php\nuse Mercure\\Update\\ViewUpdate;\n\n// Render an element\n$update = ViewUpdate::create(\n    topics: '/books/1',\n    element: 'Books/item',\n    data: ['book' =\u003e $book]\n);\n\n// Render a template with layout\n$update = ViewUpdate::create(\n    topics: '/dashboard',\n    template: 'Dashboard/stats',\n    layout: 'ajax',\n    data: ['stats' =\u003e $stats]\n);\n```\n\n### MercureDiscoveryMiddleware\n\nA PSR-15 middleware that automatically adds the Mercure discovery Link header to all responses.\n\n**Usage:**\n\n```php\n// In src/Application.php\nuse Mercure\\Http\\Middleware\\MercureDiscoveryMiddleware;\n\npublic function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue\n{\n    $middlewareQueue-\u003eadd(new MercureDiscoveryMiddleware());\n    return $middlewareQueue;\n}\n```\n\nThe middleware adds a `Link` header to every response:\n\n```\nLink: \u003chttps://mercure.example.com/.well-known/mercure\u003e; rel=\"mercure\"\n```\n\nThis allows clients to automatically discover the Mercure hub URL without hardcoding it in your application.\n\n---\n\nFor more information about the Mercure protocol, visit [mercure.rocks](https://mercure.rocks/).\n\n## Contributing\n\nContributions are welcome! Please follow these guidelines:\n\n1. **Code Quality**: Ensure all code passes quality checks:\n   ```bash\n   composer cs-check    # Check code style\n   composer stan        # Run PHPStan analysis\n   composer rector-check      # Run rectoring\n   composer test        # Run tests\n   ```\n\n2. **Code Style**: Follow CakePHP coding standards. Use `composer cs-fix` to automatically fix style issues.\n\n3. **Tests**: Add tests for new features and ensure all tests pass.\n\n4. **Documentation**: Update the README and inline documentation as needed.\n\n5. **Pull Requests**: Submit PRs against the `main` branch with a clear description of changes.\n\n## License\n\nMIT License. See [LICENSE.md](LICENSE.md) for details.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fjosbeir%2Fcakephp-mercure","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fjosbeir%2Fcakephp-mercure","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fjosbeir%2Fcakephp-mercure/lists"}