@@ -17,6 +17,7 @@ Push real-time updates to clients using the Mercure protocol.
1717- [ Installation] ( #installation )
1818 - [ Installing the Plugin] ( #installing-the-plugin )
1919 - [ Running a Mercure Hub] ( #running-a-mercure-hub )
20+ - [ Custom DDEV Setup with Nginx Proxy] ( #custom-ddev-setup-with-nginx-proxy )
2021- [ Configuration] ( #configuration )
2122- [ Basic Usage] ( #basic-usage )
2223 - [ Choosing Your Authorization Strategy] ( #choosing-your-authorization-strategy )
@@ -47,6 +48,11 @@ Push real-time updates to clients using the Mercure protocol.
4748 - [ JsonUpdate] ( #jsonupdate )
4849 - [ ViewUpdate] ( #viewupdate )
4950 - [ MercureDiscoveryMiddleware] ( #mercurediscoverymiddleware )
51+ - [ Cookbook: Queue Job Progress Tracking] ( #cookbook-queue-job-progress-tracking )
52+ - [ Creating a Progress Trait] ( #creating-a-progress-trait )
53+ - [ Using the Trait in Queue Tasks] ( #using-the-trait-in-queue-tasks )
54+ - [ Frontend: Real-time Progress Display] ( #frontend-real-time-progress-display )
55+ - [ Controller for Triggering Jobs] ( #controller-for-triggering-jobs )
5056- [ Contributing] ( #contributing )
5157- [ License] ( #license )
5258
@@ -125,6 +131,75 @@ For more information, see the [DDEV Mercure add-on](https://addons.ddev.com/addo
125131
126132The hub will be available at ` http://localhost:3000/.well-known/mercure ` .
127133
134+ ### Custom DDEV Setup with Nginx Proxy
135+
136+ For environments with dynamic ports (like DDEV), you can set up a custom Mercure container with an nginx proxy. This allows using relative URLs that work regardless of the port.
137+
138+ Create ` .ddev/docker-compose.mercure.yaml ` :
139+
140+ ``` yaml
141+ services :
142+ mercure :
143+ image : dunglas/mercure
144+ container_name : ddev-${DDEV_SITENAME}-mercure
145+ restart : " no"
146+ expose :
147+ - " 80"
148+ environment :
149+ SERVER_NAME : ' :80'
150+ MERCURE_PUBLISHER_JWT_KEY : ' !ChangeThisMercureHubJWTSecretKey!'
151+ MERCURE_SUBSCRIBER_JWT_KEY : ' !ChangeThisMercureHubJWTSecretKey!'
152+ MERCURE_EXTRA_DIRECTIVES : |
153+ anonymous
154+ cors_origins *
155+ labels :
156+ com.ddev.site-name : ${DDEV_SITENAME}
157+ com.ddev.approot : ${DDEV_APPROOT}
158+
159+ web :
160+ environment :
161+ - MERCURE_URL=http://mercure/.well-known/mercure
162+ - MERCURE_PUBLIC_URL=/mercure-hub
163+ - MERCURE_JWT_SECRET=!ChangeThisMercureHubJWTSecretKey!
164+ ` ` `
165+
166+ Create ` .ddev/nginx/mercure.conf`:
167+
168+ ` ` ` nginx
169+ location /mercure-hub {
170+ rewrite ^/mercure-hub(.*)$ /.well-known/mercure$1 break;
171+ proxy_pass http://mercure:80;
172+ proxy_http_version 1.1;
173+ proxy_set_header Connection '';
174+ proxy_set_header Cache-Control 'no-cache';
175+ proxy_set_header X-Accel-Buffering 'no';
176+ proxy_buffering off;
177+ chunked_transfer_encoding off;
178+ proxy_set_header Host $host;
179+ proxy_set_header X-Real-IP $remote_addr;
180+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
181+ proxy_set_header X-Forwarded-Proto $scheme;
182+ proxy_read_timeout 24h;
183+ proxy_connect_timeout 1h;
184+ }
185+ ` ` `
186+
187+ Then configure relative URLs in `config/app_custom.php` :
188+
189+ ` ` ` php
190+ 'Mercure' => [
191+ 'url' => env('MERCURE_URL', 'http://mercure/.well-known/mercure'),
192+ 'public_url' => env('MERCURE_PUBLIC_URL', '/mercure-hub'),
193+ 'jwt' => [
194+ 'secret' => env('MERCURE_JWT_SECRET', '!ChangeThisMercureHubJWTSecretKey!'),
195+ 'algorithm' => 'HS256',
196+ 'publish' => ['*'],
197+ ],
198+ ],
199+ ` ` `
200+
201+ Run `ddev restart` to apply the changes. Using a relative `public_url` like `/mercure-hub` means the browser will automatically use the current origin, making it work regardless of DDEV's dynamic port assignment.
202+
128203> [!TIP]
129204> **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.
130205
@@ -1267,6 +1342,285 @@ This allows clients to automatically discover the Mercure hub URL without hardco
12671342
12681343For more information about the Mercure protocol, visit [ mercure.rocks] ( https://mercure.rocks/ ) .
12691344
1345+ ## Cookbook: Queue Job Progress Tracking
1346+
1347+ A common use case for Mercure is providing real-time progress updates for background queue jobs. This cookbook shows how to integrate Mercure with [ dereuromark/cakephp-queue] ( https://github.com/dereuromark/cakephp-queue ) to display live progress bars and status updates.
1348+
1349+ ### Creating a Progress Trait
1350+
1351+ First, create a reusable trait for publishing progress updates from queue tasks:
1352+
1353+ ``` php
1354+ <?php
1355+ declare(strict_types=1);
1356+
1357+ namespace App\Queue\Task;
1358+
1359+ use Cake\Log\Log;
1360+ use Mercure\Publisher;
1361+ use Mercure\Update\JsonUpdate;
1362+ use Throwable;
1363+
1364+ /**
1365+ * Trait for publishing real-time updates from queue tasks via Mercure.
1366+ */
1367+ trait MercureProgressTrait
1368+ {
1369+ protected string $mercureTopicPrefix = '/queue/job';
1370+
1371+ /**
1372+ * Publish a progress update for a queue job.
1373+ */
1374+ protected function publishProgress(int $jobId, string $status, array $data = []): void
1375+ {
1376+ try {
1377+ $topic = sprintf('%s/%d', $this->mercureTopicPrefix, $jobId);
1378+ $payload = array_merge(['status' => $status], $data);
1379+
1380+ Publisher::publish(JsonUpdate::create(
1381+ topics: $topic,
1382+ data: $payload,
1383+ ));
1384+ } catch (Throwable $e) {
1385+ // Log but don't fail the job if Mercure is unavailable
1386+ Log::write('warning', sprintf(
1387+ 'Failed to publish Mercure update for job `%d`: %s',
1388+ $jobId,
1389+ $e->getMessage(),
1390+ ));
1391+ }
1392+ }
1393+
1394+ /**
1395+ * Publish a "started" status update.
1396+ */
1397+ protected function publishStarted(int $jobId, string $message = '', array $data = []): void
1398+ {
1399+ $payload = $data;
1400+ if ($message) {
1401+ $payload['message'] = $message;
1402+ }
1403+ $this->publishProgress($jobId, 'started', $payload);
1404+ }
1405+
1406+ /**
1407+ * Publish a progress update with percentage.
1408+ */
1409+ protected function publishProgressPercent(int $jobId, int $current, int $total, array $data = []): void
1410+ {
1411+ $percent = $total > 0 ? round(($current / $total) * 100, 1) : 0;
1412+ $payload = array_merge([
1413+ 'current' => $current,
1414+ 'total' => $total,
1415+ 'percent' => $percent,
1416+ ], $data);
1417+
1418+ $this->publishProgress($jobId, 'progress', $payload);
1419+ }
1420+
1421+ /**
1422+ * Publish a "completed" status update.
1423+ */
1424+ protected function publishCompleted(int $jobId, string $message = '', array $data = []): void
1425+ {
1426+ $payload = $data;
1427+ if ($message) {
1428+ $payload['message'] = $message;
1429+ }
1430+ $this->publishProgress($jobId, 'completed', $payload);
1431+ }
1432+
1433+ /**
1434+ * Publish a "failed" status update.
1435+ */
1436+ protected function publishFailed(int $jobId, string $error, array $data = []): void
1437+ {
1438+ $payload = array_merge(['error' => $error], $data);
1439+ $this->publishProgress($jobId, 'failed', $payload);
1440+ }
1441+ }
1442+ ```
1443+
1444+ ### Using the Trait in Queue Tasks
1445+
1446+ Use the trait in your queue task classes:
1447+
1448+ ``` php
1449+ <?php
1450+ declare(strict_types=1);
1451+
1452+ namespace App\Queue\Task;
1453+
1454+ use Queue\Queue\Task;
1455+
1456+ class ImportProductsTask extends Task
1457+ {
1458+ use MercureProgressTrait;
1459+
1460+ public function run(array $data, int $jobId): void
1461+ {
1462+ $this->publishStarted($jobId, 'Starting product import...');
1463+
1464+ $products = $this->fetchProducts($data['source']);
1465+ $total = count($products);
1466+ $saved = 0;
1467+
1468+ foreach ($products as $index => $product) {
1469+ $this->saveProduct($product);
1470+ $saved++;
1471+
1472+ // Publish progress every 10 items to avoid flooding
1473+ if ($index % 10 === 0) {
1474+ $this->publishProgressPercent($jobId, $index + 1, $total, [
1475+ 'message' => sprintf('Processing %d of %d products...', $index + 1, $total),
1476+ 'saved' => $saved,
1477+ ]);
1478+ }
1479+ }
1480+
1481+ $this->publishCompleted($jobId, sprintf('Imported %d products', $saved), [
1482+ 'total' => $total,
1483+ 'saved' => $saved,
1484+ ]);
1485+ }
1486+ }
1487+ ```
1488+
1489+ ### Frontend: Real-time Progress Display
1490+
1491+ Create a template that subscribes to job updates and displays progress:
1492+
1493+ ``` php
1494+ <?php
1495+ // templates/Admin/Jobs/monitor.php
1496+ $this->loadHelper('Mercure.Mercure');
1497+ ?>
1498+ <div class =" job-monitor" >
1499+ <h2 >Job Progress</h2 >
1500+ <div id =" job-<?= $jobId ?>" class =" job-card" >
1501+ <div class =" job-header" >
1502+ <strong ><?= h($jobTask) ?></strong >
1503+ <span class =" badge" id =" status-badge" >Pending</span >
1504+ </div >
1505+ <div class =" progress" >
1506+ <div class =" progress-bar" id =" progress-bar" style =" width : 0% " ></div >
1507+ </div >
1508+ <div id =" job-message" class =" message" >Waiting for updates...</div >
1509+ <div id =" job-stats" class =" stats" ></div >
1510+ </div >
1511+ </div >
1512+
1513+ <script >
1514+ document .addEventListener (' DOMContentLoaded' , function () {
1515+ const jobId = < ? = $jobId ? > ;
1516+ const topic = ` /queue/job/${ jobId} ` ;
1517+
1518+ // Use the Mercure helper to get the hub URL
1519+ // For relative URLs, combine with current origin
1520+ const hubUrl = ' <?= $this->Mercure->url() ?>' ;
1521+ const url = new URL (hubUrl, window .location .origin );
1522+ url .searchParams .append (' topic' , topic);
1523+
1524+ const eventSource = new EventSource (url .toString ());
1525+
1526+ eventSource .onmessage = function (event ) {
1527+ const data = JSON .parse (event .data );
1528+ updateJobDisplay (data);
1529+ };
1530+
1531+ eventSource .onerror = function () {
1532+ console .error (' EventSource connection error' );
1533+ };
1534+
1535+ function updateJobDisplay (data ) {
1536+ const statusBadge = document .getElementById (' status-badge' );
1537+ const progressBar = document .getElementById (' progress-bar' );
1538+ const message = document .getElementById (' job-message' );
1539+ const stats = document .getElementById (' job-stats' );
1540+
1541+ // Update status badge
1542+ statusBadge .textContent = data .status .charAt (0 ).toUpperCase () + data .status .slice (1 );
1543+ statusBadge .className = ' badge badge-' + getStatusColor (data .status );
1544+
1545+ // Update progress bar
1546+ if (data .percent !== undefined ) {
1547+ progressBar .style .width = data .percent + ' %' ;
1548+ }
1549+
1550+ // Update message
1551+ if (data .message ) {
1552+ message .textContent = data .message ;
1553+ }
1554+
1555+ // Update stats
1556+ let statsHtml = ' ' ;
1557+ if (data .current !== undefined ) statsHtml += ` <span>Current: ${ data .current } </span> ` ;
1558+ if (data .total !== undefined ) statsHtml += ` <span>Total: ${ data .total } </span> ` ;
1559+ if (data .saved !== undefined ) statsHtml += ` <span>Saved: ${ data .saved } </span> ` ;
1560+ if (statsHtml) stats .innerHTML = statsHtml;
1561+
1562+ // Close connection on completion or failure
1563+ if (data .status === ' completed' || data .status === ' failed' ) {
1564+ progressBar .style .width = ' 100%' ;
1565+ eventSource .close ();
1566+ }
1567+ }
1568+
1569+ function getStatusColor (status ) {
1570+ switch (status) {
1571+ case ' started' : return ' info' ;
1572+ case ' progress' : return ' warning' ;
1573+ case ' completed' : return ' success' ;
1574+ case ' failed' : return ' danger' ;
1575+ default : return ' secondary' ;
1576+ }
1577+ }
1578+ });
1579+ < / script>
1580+ ` ` `
1581+
1582+ ### Controller for Triggering Jobs
1583+
1584+ ` ` ` php
1585+ < ? php
1586+ declare (strict_types= 1 );
1587+
1588+ namespace App\Controller\Admin;
1589+
1590+ use App\Controller\AppController;
1591+ use Queue\Model\Table\QueuedJobsTable;
1592+
1593+ class JobsController extends AppController
1594+ {
1595+ public function monitor (int $id )
1596+ {
1597+ $job = $this- > fetchTable (' Queue.QueuedJobs' )- > get ($id);
1598+
1599+ $this- > set ([
1600+ ' jobId' => $job- > id,
1601+ ' jobTask' => $job- > job_task,
1602+ ]);
1603+ }
1604+
1605+ public function trigger ()
1606+ {
1607+ /** @var QueuedJobsTable $queuedJobsTable */
1608+ $queuedJobsTable = $this- > fetchTable (' Queue.QueuedJobs' );
1609+
1610+ $job = $queuedJobsTable- > createJob (
1611+ ' ImportProducts' ,
1612+ [' source' => ' api' ],
1613+ );
1614+
1615+ // Redirect to monitor page with job ID
1616+ return $this- > redirect ([' action' => ' monitor' , $job- > id]);
1617+ }
1618+ }
1619+ ` ` `
1620+
1621+ > [!TIP]
1622+ > **Throttling Updates:** For jobs that process thousands of items, avoid publishing on every iteration. Instead, publish every N items (e.g., every 10 or 100) or use time-based throttling to prevent flooding the Mercure hub.
1623+
12701624## Contributing
12711625
12721626Contributions are welcome! Please follow these guidelines:
0 commit comments