diff --git a/ProcessMaker/Http/Middleware/BrowserCache.php b/ProcessMaker/Http/Middleware/BrowserCache.php
index 4f632f86f2..39ebe90065 100644
--- a/ProcessMaker/Http/Middleware/BrowserCache.php
+++ b/ProcessMaker/Http/Middleware/BrowserCache.php
@@ -23,6 +23,10 @@ public function handle($request, Closure $next)
return $response;
}
+ if ($response->headers->has('ETag')) {
+ return $response;
+ }
+
$response->header('pragma', 'no-cache');
$response->header('Cache-Control', 'no-store');
diff --git a/ProcessMaker/Http/Middleware/Etag/TasksPageEtag.php b/ProcessMaker/Http/Middleware/Etag/TasksPageEtag.php
new file mode 100644
index 0000000000..4a43e3ed4d
--- /dev/null
+++ b/ProcessMaker/Http/Middleware/Etag/TasksPageEtag.php
@@ -0,0 +1,77 @@
+isMethod('GET') && !$request->isMethod('HEAD'))) {
+ return $next($request);
+ }
+
+ $etag = $this->tasksPageEtag->getEtag($request);
+
+ if ($this->buildResponseWithEtag($etag)->isNotModified($request)) {
+ return $this->withPrivateCacheHeaders($this->buildNotModifiedResponse($etag, $request));
+ }
+
+ $response = $next($request);
+ $response->setEtag($etag);
+
+ return $this->withPrivateCacheHeaders($response);
+ }
+
+ /**
+ * Build a framework-compatible 304 response for a matched Tasks page ETag.
+ */
+ private function buildNotModifiedResponse(string $etag, Request $request): Response
+ {
+ $response = $this->buildResponseWithEtag($etag);
+ $response->isNotModified($request);
+
+ return $response;
+ }
+
+ /**
+ * Create a response carrying the Tasks page validator.
+ *
+ * Weak ETags are used because the HTML may be transformed by gzip while the
+ * rendered representation remains equivalent for browser revalidation.
+ */
+ private function buildResponseWithEtag(string $etag): Response
+ {
+ $response = new Response();
+ $response->setEtag($etag, true);
+
+ return $response;
+ }
+
+ /**
+ * Apply browser-cache headers that allow private conditional revalidation.
+ */
+ private function withPrivateCacheHeaders(Response $response): Response
+ {
+ $response->headers->set('Cache-Control', 'private, must-revalidate');
+ $response->headers->remove('Pragma');
+ $response->headers->remove('Expires');
+
+ return $response;
+ }
+}
diff --git a/ProcessMaker/Http/Resources/Caching/TasksPageEtag.php b/ProcessMaker/Http/Resources/Caching/TasksPageEtag.php
new file mode 100644
index 0000000000..298f8d0b81
--- /dev/null
+++ b/ProcessMaker/Http/Resources/Caching/TasksPageEtag.php
@@ -0,0 +1,373 @@
+hashAlgorithm(), json_encode($this->payload($request))) . '"';
+ }
+
+ /**
+ * Build the complete content-affecting context used to validate the page.
+ *
+ * Volatile values such as CSRF tokens, session ids, and randomized asset URLs are
+ * intentionally excluded because they would prevent useful conditional requests.
+ */
+ private function payload(Request $request): array
+ {
+ $user = $request->user();
+
+ return [
+ 'route' => [
+ 'name' => $request->route()?->getName(),
+ 'path' => $request->path(),
+ 'router' => $request->route('router'),
+ 'query' => $this->sorted($request->query()),
+ ],
+ 'user' => $this->userPayload($user),
+ 'tenant' => $this->tenantPayload(),
+ 'permissions_v' => $this->permissionsVersion($user),
+ 'session_content' => [
+ 'alert' => session('_alert'),
+ 'rememberme' => session('rememberme'),
+ ],
+ 'saved_search_v' => $this->savedSearchPayload($user),
+ 'locale' => app()->getLocale(),
+ 'task_context' => [
+ 'user_filter' => $user ? SaveSession::getConfigFilter('taskFilter', $user) : null,
+ 'user_configuration' => $this->userConfigurationPayload($user),
+ 'task_drafts_enabled' => TaskDraft::draftsEnabled(),
+ ],
+ 'features_v' => $this->featuresPayload(),
+ 'packages_v' => $this->packagesPayload(),
+ ];
+ }
+
+ /**
+ * Capture user fields emitted into the layout or used by Tasks page decisions.
+ */
+ private function userPayload(?User $user): ?array
+ {
+ if (!$user) {
+ return null;
+ }
+
+ return [
+ 'id' => $user->id,
+ 'uuid' => $user->uuid,
+ 'updated_at' => $this->dateValue($user->updated_at),
+ 'is_administrator' => $user->is_administrator,
+ 'status' => $user->status,
+ 'fullname' => $user->fullname,
+ 'avatar' => $user->avatar,
+ 'datetime_format' => $user->datetime_format,
+ 'timezone' => $user->timezone,
+ 'language' => $user->language,
+ ];
+ }
+
+ /**
+ * Include tenant identity because tenant config and assets can change the page shell.
+ */
+ private function tenantPayload(): ?array
+ {
+ $tenant = app()->bound('currentTenant') ? app('currentTenant') : null;
+
+ if (!$tenant) {
+ return null;
+ }
+
+ return [
+ 'id' => $tenant->id ?? null,
+ 'updated_at' => $this->dateValue($tenant->updated_at ?? null),
+ ];
+ }
+
+ /**
+ * Version the effective permission context used by Blade and frontend props.
+ *
+ * The full permission list can be expensive to rebuild, so this uses the session
+ * snapshot plus lightweight assignment/version markers that are enough to
+ * invalidate when direct user or direct group permission assignments change.
+ */
+ private function permissionsVersion(?User $user): ?array
+ {
+ if (!$user) {
+ return null;
+ }
+
+ $directGroups = $this->directGroupPayload($user);
+
+ return [
+ 'is_administrator' => $user->is_administrator,
+ 'session_permissions' => $this->sessionPermissions(),
+ 'permissions_table' => $this->tableVersion('permissions'),
+ 'direct_user_permissions' => $this->assignablePermissionVersion(User::class, [$user->id]),
+ 'direct_groups' => $directGroups['ids'],
+ 'direct_group_memberships' => $directGroups['version'],
+ 'direct_group_permissions' => $this->assignablePermissionVersion(
+ Group::class,
+ $directGroups['ids']
+ ),
+ ];
+ }
+
+ /**
+ * Include the current session permission snapshot used by legacy permission checks.
+ */
+ private function sessionPermissions(): array
+ {
+ $permissions = session('permissions', []);
+
+ if (!is_array($permissions)) {
+ return [];
+ }
+
+ sort($permissions);
+
+ return $permissions;
+ }
+
+ /**
+ * Include the default Tasks saved search when the Saved Search package is installed.
+ */
+ private function savedSearchPayload(?User $user): ?array
+ {
+ $class = 'ProcessMaker\\Package\\SavedSearch\\Models\\SavedSearch';
+ if (!$user || !class_exists($class)) {
+ return null;
+ }
+
+ $savedSearch = $class::firstSystemSearchFor($user, $class::KEY_TASKS);
+ if (!$savedSearch) {
+ return null;
+ }
+
+ return [
+ 'id' => $savedSearch->id,
+ 'updated_at' => $this->dateValue($savedSearch->updated_at),
+ 'columns_hash' => $this->hashValue($savedSearch->columns),
+ ];
+ }
+
+ /**
+ * Capture the user UI configuration rendered into Tasks page props.
+ */
+ private function userConfigurationPayload(?User $user): array
+ {
+ if (!$user) {
+ return UserConfigurationController::DEFAULT_USER_CONFIGURATION;
+ }
+
+ $configuration = UserConfiguration::select('updated_at', 'ui_configuration')
+ ->where('user_id', $user->id)
+ ->first();
+ if (!$configuration) {
+ return [
+ 'updated_at' => null,
+ 'ui_configuration_hash' => $this->hashValue(UserConfigurationController::DEFAULT_USER_CONFIGURATION),
+ ];
+ }
+
+ return [
+ 'updated_at' => $this->dateValue($configuration->updated_at),
+ 'ui_configuration_hash' => $this->hashValue($configuration->ui_configuration),
+ ];
+ }
+
+ /**
+ * Return direct group ids and their version marker without loading group models.
+ */
+ private function directGroupPayload(User $user): array
+ {
+ $memberships = DB::table('group_members')
+ ->where('member_type', User::class)
+ ->where('member_id', $user->id)
+ ->orderBy('group_id')
+ ->get(['group_id', 'updated_at']);
+
+ return [
+ 'ids' => $memberships->pluck('group_id')->all(),
+ 'version' => [
+ 'count' => $memberships->count(),
+ 'updated_at' => $this->dateValue($memberships->max('updated_at')),
+ ],
+ ];
+ }
+
+ /**
+ * Hash direct permission assignment ids for an assignable type.
+ */
+ private function assignablePermissionVersion(string $assignableType, array $assignableIds): array
+ {
+ if (empty($assignableIds)) {
+ return [
+ 'count' => 0,
+ 'permission_ids_hash' => $this->hashValue([]),
+ ];
+ }
+
+ $permissionIds = DB::table('assignables')
+ ->where('assignable_type', $assignableType)
+ ->whereIn('assignable_id', $assignableIds)
+ ->orderBy('permission_id')
+ ->pluck('permission_id')
+ ->all();
+
+ return [
+ 'count' => count($permissionIds),
+ 'permission_ids_hash' => $this->hashValue($permissionIds),
+ ];
+ }
+
+ /**
+ * Version a table with a compact count and updated_at marker.
+ */
+ private function tableVersion(string $table): array
+ {
+ $version = DB::table($table)
+ ->selectRaw('COUNT(*) as count, MAX(updated_at) as updated_at')
+ ->first();
+
+ return [
+ 'count' => (int) ($version->count ?? 0),
+ 'updated_at' => $this->dateValue($version->updated_at ?? null),
+ ];
+ }
+
+ /**
+ * Hash structured values before placing them in the ETag payload.
+ */
+ private function hashValue($value): string
+ {
+ return hash($this->hashAlgorithm(), json_encode($value));
+ }
+
+ /**
+ * Capture selected config values and frontend asset versions used by the page shell.
+ */
+ private function featuresPayload(): array
+ {
+ $features = [];
+ foreach (self::FEATURE_CONFIG_KEYS as $key) {
+ Arr::set($features, $key, config($key));
+ }
+
+ $features['mix_manifest'] = $this->fileVersion(public_path('mix-manifest.json'));
+
+ return $features;
+ }
+
+ /**
+ * Version installed package state so package-provided UI changes invalidate the page.
+ */
+ private function packagesPayload(): array
+ {
+ $packages = app(PackageManager::class)->listPackages();
+ sort($packages);
+
+ $manifest = app(PackageManifest::class);
+
+ return [
+ 'app_version' => $this->appVersion(),
+ 'registered' => $packages,
+ 'manifest' => method_exists($manifest, 'list') ? $manifest->list() : $manifest->providers(),
+ 'composer_lock' => $this->fileVersion(base_path('composer.lock')),
+ ];
+ }
+
+ /**
+ * Read the ProcessMaker application version from composer metadata.
+ */
+ private function appVersion(): ?string
+ {
+ $composer = json_decode(File::get(base_path('composer.json')), true);
+
+ return $composer['version'] ?? null;
+ }
+
+ /**
+ * Return a cheap version marker for files that affect rendered assets or packages.
+ */
+ private function fileVersion(string $path): ?array
+ {
+ if (!File::exists($path)) {
+ return null;
+ }
+
+ return [
+ 'mtime' => File::lastModified($path),
+ 'hash' => hash_file($this->hashAlgorithm(), $path),
+ ];
+ }
+
+ /**
+ * Prefer xxh128 when available and fall back for older runtimes.
+ */
+ private function hashAlgorithm(): string
+ {
+ return in_array('xxh128', hash_algos(), true) ? 'xxh128' : 'sha256';
+ }
+
+ /**
+ * Normalize nullable date values for deterministic JSON hashing.
+ */
+ private function dateValue($value): ?string
+ {
+ return $value ? (string) $value : null;
+ }
+
+ /**
+ * Recursively sort arrays so query-string order does not change the ETag.
+ */
+ private function sorted(array $value): array
+ {
+ ksort($value);
+
+ foreach ($value as $key => $item) {
+ if (is_array($item)) {
+ $value[$key] = $this->sorted($item);
+ }
+ }
+
+ return $value;
+ }
+}
diff --git a/bootstrap/app.php b/bootstrap/app.php
index d4f47576d4..7f9b9519a8 100644
--- a/bootstrap/app.php
+++ b/bootstrap/app.php
@@ -114,6 +114,7 @@
'admin' => ProcessMakerMiddleware\IsAdmin::class,
'manager' => ProcessMakerMiddleware\IsManager::class,
'etag' => ProcessMakerMiddleware\Etag\HandleEtag::class,
+ 'tasks-page-etag' => ProcessMakerMiddleware\Etag\TasksPageEtag::class,
'file_size_check' => ProcessMakerMiddleware\FileSizeCheck::class,
'auth.basic' => Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'throttle' => Illuminate\Routing\Middleware\ThrottleRequests::class,
diff --git a/docs/tasks-page-etag-sequence.md b/docs/tasks-page-etag-sequence.md
new file mode 100644
index 0000000000..68efe9c5cc
--- /dev/null
+++ b/docs/tasks-page-etag-sequence.md
@@ -0,0 +1,95 @@
+# Tasks Page ETag Sequence
+
+This diagram captures the current request flow for the Tasks page shell (`/tasks`). The route-specific middleware computes a stable Tasks page ETag before rendering, short-circuits matching conditional requests with `304 Not Modified`, and sets private revalidation headers. The legacy inbox route (`/inbox/{router?}`) remains on the original `no-cache` middleware path and does not use this ETag flow. The global browser-cache middleware preserves ETag-enabled responses instead of applying `no-store`.
+
+
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant Browser as Browser
+ participant BrowserCache as BrowserCache
ProcessMaker\Http\Middleware\BrowserCache
+ participant Router as Laravel Router
+ participant TasksPageEtagMw as TasksPageEtag middleware
ProcessMaker\Http\Middleware\Etag\TasksPageEtag
+ participant Payload as TasksPageEtag payload
ProcessMaker\Http\Resources\Caching\TasksPageEtag
+ participant SymfonyResponse as Response
Symfony\Component\HttpFoundation\Response
+ participant Controller as TaskController@index
ProcessMaker\Http\Controllers\TaskController
+
+ Browser->>BrowserCache: GET /tasks
Cookie + optional If-None-Match
+ BrowserCache->>Router: pass request through global middleware stack
+ Router->>TasksPageEtagMw: dispatch route with tasks-page-etag middleware
+
+ alt ETags disabled or method is not GET/HEAD
+ TasksPageEtagMw->>Controller: bypass ETag logic
+ Controller-->>TasksPageEtagMw: normal page response
+ TasksPageEtagMw-->>BrowserCache: response without Tasks page ETag handling
+ else ETags enabled and method is GET/HEAD
+ TasksPageEtagMw->>Payload: getEtag(request)
+ Payload->>Payload: collect route name/path/router/query
+ Payload->>Payload: collect user and tenant version markers
+ Payload->>Payload: collect permission table/session/direct assignment markers
+ Payload->>Payload: collect saved search id/updated_at/columns hash
+ Payload->>Payload: collect user config hash and task drafts flag
+ Payload->>Payload: collect feature config, package list, manifest, asset versions
+ Payload->>Payload: exclude CSRF, session id, randomized favicon URL
+ Payload-->>TasksPageEtagMw: quoted stable hash
+ TasksPageEtagMw->>SymfonyResponse: create empty response + set weak ETag
+ TasksPageEtagMw->>SymfonyResponse: isNotModified(request)
+
+ alt If-None-Match matches weak ETag
+ SymfonyResponse-->>TasksPageEtagMw: true
+ TasksPageEtagMw->>SymfonyResponse: build 304 response with same weak ETag
+ TasksPageEtagMw->>TasksPageEtagMw: Cache-Control: private, must-revalidate
+ TasksPageEtagMw->>TasksPageEtagMw: remove Pragma and Expires
+ TasksPageEtagMw-->>BrowserCache: 304 Not Modified
+ else Missing/stale If-None-Match
+ SymfonyResponse-->>TasksPageEtagMw: false
+ TasksPageEtagMw->>Controller: render Tasks page shell
+ Controller->>Controller: resolve title, router mode, mobile check
+ Controller->>Controller: load ScreenBuilderManager scripts
+ Controller->>Controller: load task filter, default columns, drafts flag
+ Controller->>Controller: load user configuration and default saved search
+ Controller-->>TasksPageEtagMw: tasks.index response
+ TasksPageEtagMw->>TasksPageEtagMw: attach weak ETag to 200 response
+ TasksPageEtagMw->>TasksPageEtagMw: Cache-Control: private, must-revalidate
+ TasksPageEtagMw->>TasksPageEtagMw: remove Pragma and Expires
+ TasksPageEtagMw-->>BrowserCache: 200 OK with weak ETag
+ end
+ end
+
+ alt Response has ETag
+ BrowserCache->>BrowserCache: preserve response headers
+ BrowserCache->>BrowserCache: skip no-store / Pragma override
+ else No ETag and BROWSER_CACHE=false
+ BrowserCache->>BrowserCache: add Pragma: no-cache
+ BrowserCache->>BrowserCache: add Cache-Control: no-store
+ end
+
+ BrowserCache-->>Browser: 200 OK + ETag or 304 Not Modified
+ Browser->>Browser: Store private validator and send If-None-Match on later reload
+```
+
+## ETag Context
+
+The payload intentionally includes content-affecting values:
+
+- Route path/query/router state.
+- Authenticated user id, update timestamp, locale, timezone, display fields, and admin status.
+- Tenant id and tenant update timestamp when present.
+- Permission table version, session permission snapshot, direct user/group assignment hashes, and direct group membership version.
+- Task filter cache, user configuration hash, task draft flag, and saved search defaults hash.
+- Page-relevant feature config, registered package list, package manifest, app version, `composer.lock`, and `mix-manifest.json`.
+
+The payload intentionally excludes volatile values that do not define the rendered page, such as CSRF token, session id, and randomized favicon URLs.
+
+## Header Outcome
+
+Successful Tasks page responses should use private revalidation rather than storage blocking:
+
+```http
+Cache-Control: private, must-revalidate
+ETag: W/"..."
+Vary: Accept-Encoding
+```
+
+They should not include `no-store` or `Pragma: no-cache`; otherwise the browser will not keep the validator and will not send `If-None-Match`.
diff --git a/routes/web.php b/routes/web.php
index c0cf1141bf..a8ca8fbb07 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -206,7 +206,7 @@
Route::get('tasks/search', [TaskController::class, 'search'])->name('tasks.search');
Route::get('tasks', [TaskController::class, 'index'])
->name('tasks.index')
- ->middleware('no-cache');
+ ->middleware('tasks-page-etag');
Route::get('tasks/{task}/edit', [TaskController::class, 'edit'])->name('tasks.edit');
Route::get('tasks/{task}/edit/quickfill', [TaskController::class, 'quickFillEdit'])->name('tasks.edit.quickfill');
Route::get('tasks/{task}/edit/{preview}', [TaskController::class, 'edit'])->name('tasks.preview');
diff --git a/tests/Feature/TasksTest.php b/tests/Feature/TasksTest.php
index e63b2bb18d..6fb7aa3b57 100644
--- a/tests/Feature/TasksTest.php
+++ b/tests/Feature/TasksTest.php
@@ -7,6 +7,7 @@
use ProcessMaker\Models\ProcessRequestToken;
use ProcessMaker\Models\ProcessTaskAssignment;
use ProcessMaker\Models\User;
+use ProcessMaker\Models\UserConfiguration;
use Tests\Feature\Shared\RequestHelper;
use Tests\TestCase;
@@ -39,6 +40,79 @@ public function testIndex()
$response->assertSee('Tasks');
}
+ public function testTasksPageSendsPrivateEtagHeaders()
+ {
+ $response = $this->webGet(self::TASKS_URL, []);
+
+ $response->assertStatus(200);
+ $response->assertHeader('ETag');
+ $this->assertCacheControlHasPrivateMustRevalidate($response);
+ $response->assertHeaderMissing('Pragma');
+ $response->assertHeaderMissing('Expires');
+ }
+
+ public function testTasksPageReturnsNotModifiedWhenEtagMatches()
+ {
+ $response = $this->webGet(self::TASKS_URL, []);
+ $etag = $response->headers->get('ETag');
+
+ $responseWithMatchingEtag = $this->actingAs($this->user, 'web')
+ ->withHeaders(['If-None-Match' => $etag])
+ ->get(self::TASKS_URL);
+
+ $responseWithMatchingEtag->assertStatus(304);
+ $this->assertEquals(
+ $this->stripWeakEtagPrefix($etag),
+ $this->stripWeakEtagPrefix($responseWithMatchingEtag->headers->get('ETag'))
+ );
+ $this->assertCacheControlHasPrivateMustRevalidate($responseWithMatchingEtag);
+ $this->assertEmpty($responseWithMatchingEtag->getContent());
+ }
+
+ public function testTasksPageEtagChangesWhenUserConfigurationChanges()
+ {
+ $response = $this->webGet(self::TASKS_URL, []);
+ $etag = $response->headers->get('ETag');
+
+ UserConfiguration::create([
+ 'user_id' => $this->user->id,
+ 'ui_configuration' => json_encode([
+ 'tasks' => [
+ 'isMenuCollapse' => false,
+ ],
+ ]),
+ ]);
+
+ $updatedResponse = $this->webGet(self::TASKS_URL, []);
+
+ $this->assertNotEquals($etag, $updatedResponse->headers->get('ETag'));
+ }
+
+ public function testTasksPageEtagChangesWhenFeatureConfigChanges()
+ {
+ $response = $this->webGet(self::TASKS_URL, []);
+ $etag = $response->headers->get('ETag');
+
+ config()->set('app.task_drafts_enabled', !config('app.task_drafts_enabled'));
+
+ $updatedResponse = $this->webGet(self::TASKS_URL, []);
+
+ $this->assertNotEquals($etag, $updatedResponse->headers->get('ETag'));
+ }
+
+ private function assertCacheControlHasPrivateMustRevalidate($response): void
+ {
+ $cacheControl = $response->headers->get('Cache-Control');
+
+ $this->assertStringContainsString('private', $cacheControl);
+ $this->assertStringContainsString('must-revalidate', $cacheControl);
+ }
+
+ private function stripWeakEtagPrefix(?string $etag): ?string
+ {
+ return $etag ? str_replace('W/', '', $etag) : null;
+ }
+
public function testViewTaskWithComments()
{
//Start a process request