Skip to content

Conversation

@dunglas
Copy link
Member

@dunglas dunglas commented Nov 24, 2025

This patch brings hot reloading capabilities to PHP apps: in development, the browser will automatically refresh the page when any source file changes!
It's similar to HMR in JavaScript.

It is built on top of the watcher mechanism and of the Mercure integration.

Each time a watched file is modified, a Mercure update is sent, giving the ability to the client to reload the page, or part of the page (assets, images...).

Here is an example implementation:

root ./public


mercure {
      subscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY}
      anonymous
}

php_server {
      hot_reload
}
<?php
header('Content-Type: text/html');
?>
<!DOCTYPE html>
<html lang="en">
<head>
<title>Test</title>
<script>
    const es = new EventSource('<?=$_SERVER['FRANKENPHP_HOT_RELOAD']?>');
    es.onmessage = () => location.reload();
</script>
</head>
<body>
Hello

I plan to create a helper JS library to handle more advanced cases (reloading CSS, JS, etc), similar to HotWire Spark. Be sure to attend my SymfonyCon to learn more!

There is still room for improvement:

  • Provide an option to only trigger the update without reloading the worker for some files (ex, images, JS, CSS...)
  • Support classic mode (currently, only the worker mode is supported)
  • Don't reload all workers when only the files used by one change

However, this PR is working as-is and can be merged as a first step.

This patch heavily refactors the watcher module. Maybe it will be possible to extract it as a standalone library at some point (would be useful to add a similar feature but not tight to PHP as a Caddy module).

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces hot reloading capabilities for PHP applications in FrankenPHP, enabling automatic browser refresh when source files change during development. The implementation is built on top of the existing watcher mechanism and Mercure integration, sending Mercure updates when watched files are modified so clients can reload the page or specific assets.

Key changes include:

  • Refactored watcher module to support per-pattern callbacks and event metadata
  • Added Mercure integration to publish file change events as hot reloading updates
  • Introduced new Event type system with structured event types (EffectType, PathType) and JSON marshaling

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
worker.go Integrates watcher patterns with worker-specific hot reloading callbacks and configures Mercure hub
state.go Code formatting improvements and initialization cleanup
options.go Adds mercureContext field to workerOpt struct
mercure.go Implements hot reloading update publishing via Mercure hub and environment variable configuration
mercure-skip.go Provides stub implementations for nomercure build tag
internal/watcher/watcher.h Updates C function signatures with renamed parameters
internal/watcher/watcher.go Major refactor: introduces PatternGroup concept, per-pattern callbacks, and event-driven architecture
internal/watcher/watcher.c Updates C event handler to pass full event struct
internal/watcher/watcher-skip.go Provides stub implementation for nowatcher build tag
internal/watcher/watch_pattern_test.go Updates tests to use new Event-based API
internal/watcher/watch_pattern.go Refactors pattern matching to work with Event objects and adds CGO integration for watcher events
internal/watcher/types.go Defines Event type system with EffectType and PathType enums and JSON marshaling
caddy/workerconfig.go Adds mercureContext field to workerConfig
caddy/module.go Updates to use new assignMercureHub method name
caddy/mercure.go Implements Mercure hub assignment for workers and adds appendMercureHubOption helper
caddy/mercure-skip.go Provides stub implementations for nomercure build tag with corrected function name
caddy/app.go Simplifies worker options initialization using appendMercureHubOption

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@henderkes
Copy link
Contributor

I'll add a review later, but very cool! I currently have this running through PhpStorm (forces a F5), but we could even go further with mercure + turbo and send a morph update (configurable by users, of course).

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 25 out of 25 changed files in this pull request and generated 9 comments.

Comments suppressed due to low confidence (1)

internal/watcher/watcher.go:77

  • If activeWatcher.startWatching() returns an error (line 73), the function returns immediately without cleaning up. The watcherIsActive atomic bool is set to true (line 60), but it's not reset to false on error. This could prevent subsequent attempts to initialize the watcher. Consider adding cleanup logic:
if err := activeWatcher.startWatching(); err != nil {
	watcherIsActive.Store(false)
	activeWatcher = nil
	return err
}
	if err := activeWatcher.startWatching(); err != nil {
		return err
	}

	return nil

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 25 out of 25 changed files in this pull request and generated 8 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

dunglas and others added 12 commits December 1, 2025 16:56
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Comment on lines 139 to 144
for i, w := range g.watchers {
w.events = g.events
if err := w.startSession(); err != nil {
for j := 0; j < i; j++ {
g.watchers[j].stop()
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would the mercure events start a separate instance of the watcher here?

Copy link
Contributor

@AlliBalliBaba AlliBalliBaba Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm I think all this would be much easier if we just stick to 1 callback, something like this:

func(events) {
    if watchingWorkers {
        RestartWorkers()
    }
    if watchingWithMercure{
        broadcastHotReloadEvents(events)
    }
}()

Or if the callback of the pattern group already contains both worker restarting and hot reload broadcasting

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if it is worth it, because patterns can be different for hot reloading and for Mercure.
For instance, you don't want to restart the workers when a JS file changes, but you want to notify through Mercure.

@AlliBalliBaba
Copy link
Contributor

Just tested this branch and it's actually a pretty cool feature 👍 .

Some more things I noticed:

  • the server will hang forever on shutdown if someone is still listening on the mercure endpoint, I think ideally everyone should just be disconnected.

  • on file change, if both watching and hot-reloading are active, every event will be logged twice:

Screenshot 2025-12-06 135037
  • log and message can pretty much grow limitlessly if lots of file changes are done in quick succession. There probably should be some kind of cap on the amount of file events that can be forwarded at once.

@dunglas
Copy link
Member Author

dunglas commented Dec 6, 2025

The "eternal grace" issue is something that I should fix Mercure-side and is unrelated to this specific feature.
A workaround is to configure an explicit grace period in the Caddyfile.

Copy link
Member

@withinboredom withinboredom left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is actually really cool. I'm pretty excited to use it on my hugo generator. Developing with it is a pita because its mostly generated html (other than the API + interactive blog posts), and this would work perfectly for that.

@dunglas dunglas merged commit 225ca40 into main Dec 12, 2025
71 of 88 checks passed
@dunglas dunglas deleted the feat/hot-reloading branch December 12, 2025 13:29
AlliBalliBaba added a commit that referenced this pull request Jan 10, 2026
Fixes #2114

This early
[Shutdown](https://github.com/php/frankenphp/blob/11160fb7b31171d706cf9933abb9102dbb1cdb3c/frankenphp.go#L281)
introduced in #2031 segfaults instead of returning an error since
threads have not started yet.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants