diff --git a/caddy.go b/caddy.go
index 758b0b2f6..d6a2ae0b3 100644
--- a/caddy.go
+++ b/caddy.go
@@ -81,13 +81,14 @@ type Config struct {
 	// associated value.
 	AppsRaw ModuleMap `json:"apps,omitempty" caddy:"namespace="`
 
-	apps    map[string]App
-	storage certmagic.Storage
+	apps         map[string]App
+	storage      certmagic.Storage
+	eventEmitter eventEmitter
 
 	cancelFunc context.CancelFunc
 
-	// filesystems is a dict of filesystems that will later be loaded from and added to.
-	filesystems FileSystems
+	// fileSystems is a dict of fileSystems that will later be loaded from and added to.
+	fileSystems FileSystems
 }
 
 // App is a thing that Caddy runs.
@@ -442,6 +443,10 @@ func run(newCfg *Config, start bool) (Context, error) {
 	}
 	globalMetrics.configSuccess.Set(1)
 	globalMetrics.configSuccessTime.SetToCurrentTime()
+
+	// TODO: This event is experimental and subject to change.
+	ctx.emitEvent("started", nil)
+
 	// now that the user's config is running, finish setting up anything else,
 	// such as remote admin endpoint, config loader, etc.
 	return ctx, finishSettingUp(ctx, ctx.cfg)
@@ -509,7 +514,7 @@ func provisionContext(newCfg *Config, replaceAdminServer bool) (Context, error)
 	}
 
 	// create the new filesystem map
-	newCfg.filesystems = &filesystems.FilesystemMap{}
+	newCfg.fileSystems = &filesystems.FileSystemMap{}
 
 	// prepare the new config for use
 	newCfg.apps = make(map[string]App)
@@ -696,6 +701,9 @@ func unsyncedStop(ctx Context) {
 		return
 	}
 
+	// TODO: This event is experimental and subject to change.
+	ctx.emitEvent("stopping", nil)
+
 	// stop each app
 	for name, a := range ctx.cfg.apps {
 		err := a.Stop()
@@ -1038,6 +1046,92 @@ func Version() (simple, full string) {
 	return
 }
 
+// Event represents something that has happened or is happening.
+// An Event value is not synchronized, so it should be copied if
+// being used in goroutines.
+//
+// EXPERIMENTAL: Events are subject to change.
+type Event struct {
+	// If non-nil, the event has been aborted, meaning
+	// propagation has stopped to other handlers and
+	// the code should stop what it was doing. Emitters
+	// may choose to use this as a signal to adjust their
+	// code path appropriately.
+	Aborted error
+
+	// The data associated with the event. Usually the
+	// original emitter will be the only one to set or
+	// change these values, but the field is exported
+	// so handlers can have full access if needed.
+	// However, this map is not synchronized, so
+	// handlers must not use this map directly in new
+	// goroutines; instead, copy the map to use it in a
+	// goroutine. Data may be nil.
+	Data map[string]any
+
+	id     uuid.UUID
+	ts     time.Time
+	name   string
+	origin Module
+}
+
+// NewEvent creates a new event, but does not emit the event. To emit an
+// event, call Emit() on the current instance of the caddyevents app insteaad.
+//
+// EXPERIMENTAL: Subject to change.
+func NewEvent(ctx Context, name string, data map[string]any) (Event, error) {
+	id, err := uuid.NewRandom()
+	if err != nil {
+		return Event{}, fmt.Errorf("generating new event ID: %v", err)
+	}
+	name = strings.ToLower(name)
+	return Event{
+		Data:   data,
+		id:     id,
+		ts:     time.Now(),
+		name:   name,
+		origin: ctx.Module(),
+	}, nil
+}
+
+func (e Event) ID() uuid.UUID        { return e.id }
+func (e Event) Timestamp() time.Time { return e.ts }
+func (e Event) Name() string         { return e.name }
+func (e Event) Origin() Module       { return e.origin } // Returns the module that originated the event. May be nil, usually if caddy core emits the event.
+
+// CloudEvent exports event e as a structure that, when
+// serialized as JSON, is compatible with the
+// CloudEvents spec.
+func (e Event) CloudEvent() CloudEvent {
+	dataJSON, _ := json.Marshal(e.Data)
+	return CloudEvent{
+		ID:              e.id.String(),
+		Source:          e.origin.CaddyModule().String(),
+		SpecVersion:     "1.0",
+		Type:            e.name,
+		Time:            e.ts,
+		DataContentType: "application/json",
+		Data:            dataJSON,
+	}
+}
+
+// CloudEvent is a JSON-serializable structure that
+// is compatible with the CloudEvents specification.
+// See https://cloudevents.io.
+// EXPERIMENTAL: Subject to change.
+type CloudEvent struct {
+	ID              string          `json:"id"`
+	Source          string          `json:"source"`
+	SpecVersion     string          `json:"specversion"`
+	Type            string          `json:"type"`
+	Time            time.Time       `json:"time"`
+	DataContentType string          `json:"datacontenttype,omitempty"`
+	Data            json.RawMessage `json:"data,omitempty"`
+}
+
+// ErrEventAborted cancels an event.
+var ErrEventAborted = errors.New("event aborted")
+
 // ActiveContext returns the currently-active context.
 // This function is experimental and might be changed
 // or removed in the future.
diff --git a/context.go b/context.go
index 94623df72..a65814f03 100644
--- a/context.go
+++ b/context.go
@@ -91,14 +91,14 @@ func (ctx *Context) OnCancel(f func()) {
 	ctx.cleanupFuncs = append(ctx.cleanupFuncs, f)
 }
 
-// Filesystems returns a ref to the FilesystemMap.
+// FileSystems returns a ref to the FilesystemMap.
 // EXPERIMENTAL: This API is subject to change.
-func (ctx *Context) Filesystems() FileSystems {
+func (ctx *Context) FileSystems() FileSystems {
 	// if no config is loaded, we use a default filesystemmap, which includes the osfs
 	if ctx.cfg == nil {
-		return &filesystems.FilesystemMap{}
+		return &filesystems.FileSystemMap{}
 	}
-	return ctx.cfg.filesystems
+	return ctx.cfg.fileSystems
 }
 
 // Returns the active metrics registry for the context
@@ -277,6 +277,14 @@ func (ctx Context) LoadModule(structPointer any, fieldName string) (any, error)
 	return result, nil
 }
 
+// emitEvent is a small convenience method so the caddy core can emit events, if the event app is configured.
+func (ctx Context) emitEvent(name string, data map[string]any) Event {
+	if ctx.cfg == nil || ctx.cfg.eventEmitter == nil {
+		return Event{}
+	}
+	return ctx.cfg.eventEmitter.Emit(ctx, name, data)
+}
+
 // loadModulesFromSomeMap loads modules from val, which must be a type of map[string]any.
 // Depending on inlineModuleKey, it will be interpreted as either a ModuleMap (key is the module
 // name) or as a regular map (key is not the module name, and module name is defined inline).
@@ -429,6 +437,14 @@ func (ctx Context) LoadModuleByID(id string, rawMsg json.RawMessage) (any, error
 
 	ctx.moduleInstances[id] = append(ctx.moduleInstances[id], val)
 
+	// if the loaded module happens to be an app that can emit events, store it so the
+	// core can have access to emit events without an import cycle
+	if ee, ok := val.(eventEmitter); ok {
+		if _, ok := ee.(App); ok {
+			ctx.cfg.eventEmitter = ee
+		}
+	}
+
 	return val, nil
 }
 
@@ -600,3 +616,11 @@ func (ctx *Context) WithValue(key, value any) Context {
 		exitFuncs:       ctx.exitFuncs,
 	}
 }
+
+// eventEmitter is a small interface that inverts dependencies for
+// the caddyevents package, so the core can emit events without an
+// import cycle (i.e. the caddy package doesn't have to import
+// the caddyevents package, which imports the caddy package).
+type eventEmitter interface {
+	Emit(ctx Context, eventName string, data map[string]any) Event
+}
diff --git a/internal/filesystems/map.go b/internal/filesystems/map.go
index e795ed1fe..3ecb34e40 100644
--- a/internal/filesystems/map.go
+++ b/internal/filesystems/map.go
@@ -7,10 +7,10 @@ import (
 )
 
 const (
-	DefaultFilesystemKey = "default"
+	DefaultFileSystemKey = "default"
 )
 
-var DefaultFilesystem = &wrapperFs{key: DefaultFilesystemKey, FS: OsFS{}}
+var DefaultFileSystem = &wrapperFs{key: DefaultFileSystemKey, FS: OsFS{}}
 
 // wrapperFs exists so can easily add to wrapperFs down the line
 type wrapperFs struct {
@@ -18,24 +18,24 @@ type wrapperFs struct {
 	fs.FS
 }
 
-// FilesystemMap stores a map of filesystems
+// FileSystemMap stores a map of filesystems
 // the empty key will be overwritten to be the default key
 // it includes a default filesystem, based off the os fs
-type FilesystemMap struct {
+type FileSystemMap struct {
 	m sync.Map
 }
 
 // note that the first invocation of key cannot be called in a racy context.
-func (f *FilesystemMap) key(k string) string {
+func (f *FileSystemMap) key(k string) string {
 	if k == "" {
-		k = DefaultFilesystemKey
+		k = DefaultFileSystemKey
 	}
 	return k
 }
 
 // Register will add the filesystem with key to later be retrieved
 // A call with a nil fs will call unregister, ensuring that a call to Default() will never be nil
-func (f *FilesystemMap) Register(k string, v fs.FS) {
+func (f *FileSystemMap) Register(k string, v fs.FS) {
 	k = f.key(k)
 	if v == nil {
 		f.Unregister(k)
@@ -47,23 +47,23 @@ func (f *FilesystemMap) Register(k string, v fs.FS) {
 // Unregister will remove the filesystem with key from the filesystem map
 // if the key is the default key, it will set the default to the osFS instead of deleting it
 // modules should call this on cleanup to be safe
-func (f *FilesystemMap) Unregister(k string) {
+func (f *FileSystemMap) Unregister(k string) {
 	k = f.key(k)
-	if k == DefaultFilesystemKey {
-		f.m.Store(k, DefaultFilesystem)
+	if k == DefaultFileSystemKey {
+		f.m.Store(k, DefaultFileSystem)
 	} else {
 		f.m.Delete(k)
 	}
 }
 
 // Get will get a filesystem with a given key
-func (f *FilesystemMap) Get(k string) (v fs.FS, ok bool) {
+func (f *FileSystemMap) Get(k string) (v fs.FS, ok bool) {
 	k = f.key(k)
 	c, ok := f.m.Load(strings.TrimSpace(k))
 	if !ok {
-		if k == DefaultFilesystemKey {
-			f.m.Store(k, DefaultFilesystem)
-			return DefaultFilesystem, true
+		if k == DefaultFileSystemKey {
+			f.m.Store(k, DefaultFileSystem)
+			return DefaultFileSystem, true
 		}
 		return nil, ok
 	}
@@ -71,7 +71,7 @@ func (f *FilesystemMap) Get(k string) (v fs.FS, ok bool) {
 }
 
 // Default will get the default filesystem in the filesystem map
-func (f *FilesystemMap) Default() fs.FS {
-	val, _ := f.Get(DefaultFilesystemKey)
+func (f *FileSystemMap) Default() fs.FS {
+	val, _ := f.Get(DefaultFileSystemKey)
 	return val
 }
diff --git a/modules/caddyevents/app.go b/modules/caddyevents/app.go
index e78b00f8c..9fc8fa8ed 100644
--- a/modules/caddyevents/app.go
+++ b/modules/caddyevents/app.go
@@ -20,9 +20,7 @@ import (
 	"errors"
 	"fmt"
 	"strings"
-	"time"
 
-	"github.com/google/uuid"
 	"go.uber.org/zap"
 
 	"github.com/caddyserver/caddy/v2"
@@ -206,27 +204,26 @@ func (app *App) On(eventName string, handler Handler) error {
 //
 // Note that the data map is not copied, for efficiency. After Emit() is called, the
 // data passed in should not be changed in other goroutines.
-func (app *App) Emit(ctx caddy.Context, eventName string, data map[string]any) Event {
+func (app *App) Emit(ctx caddy.Context, eventName string, data map[string]any) caddy.Event {
 	logger := app.logger.With(zap.String("name", eventName))
 
-	id, err := uuid.NewRandom()
+	e, err := caddy.NewEvent(ctx, eventName, data)
 	if err != nil {
-		logger.Error("failed generating new event ID", zap.Error(err))
+		logger.Error("failed to create event", zap.Error(err))
 	}
 
-	eventName = strings.ToLower(eventName)
-
-	e := Event{
-		Data:   data,
-		id:     id,
-		ts:     time.Now(),
-		name:   eventName,
-		origin: ctx.Module(),
+	var originModule caddy.ModuleInfo
+	var originModuleID caddy.ModuleID
+	var originModuleName string
+	if origin := e.Origin(); origin != nil {
+		originModule = origin.CaddyModule()
+		originModuleID = originModule.ID
+		originModuleName = originModule.String()
 	}
 
 	logger = logger.With(
-		zap.String("id", e.id.String()),
-		zap.String("origin", e.origin.CaddyModule().String()))
+		zap.String("id", e.ID().String()),
+		zap.String("origin", originModuleName))
 
 	// add event info to replacer, make sure it's in the context
 	repl, ok := ctx.Context.Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
@@ -239,15 +236,15 @@ func (app *App) Emit(ctx caddy.Context, eventName string, data map[string]any) E
 		case "event":
 			return e, true
 		case "event.id":
-			return e.id, true
+			return e.ID(), true
 		case "event.name":
-			return e.name, true
+			return e.Name(), true
 		case "event.time":
-			return e.ts, true
+			return e.Timestamp(), true
 		case "event.time_unix":
-			return e.ts.UnixMilli(), true
+			return e.Timestamp().UnixMilli(), true
 		case "event.module":
-			return e.origin.CaddyModule().ID, true
+			return originModuleID, true
 		case "event.data":
 			return e.Data, true
 		}
@@ -269,7 +266,7 @@ func (app *App) Emit(ctx caddy.Context, eventName string, data map[string]any) E
 	// invoke handlers bound to the event by name and also all events; this for loop
 	// iterates twice at most: once for the event name, once for "" (all events)
 	for {
-		moduleID := e.origin.CaddyModule().ID
+		moduleID := originModuleID
 
 		// implement propagation up the module tree (i.e. start with "a.b.c" then "a.b" then "a" then "")
 		for {
@@ -292,7 +289,7 @@ func (app *App) Emit(ctx caddy.Context, eventName string, data map[string]any) E
 					zap.Any("handler", handler))
 
 				if err := handler.Handle(ctx, e); err != nil {
-					aborted := errors.Is(err, ErrAborted)
+					aborted := errors.Is(err, caddy.ErrEventAborted)
 
 					logger.Error("handler error",
 						zap.Error(err),
@@ -326,76 +323,9 @@ func (app *App) Emit(ctx caddy.Context, eventName string, data map[string]any) E
 	return e
 }
 
-// Event represents something that has happened or is happening.
-// An Event value is not synchronized, so it should be copied if
-// being used in goroutines.
-//
-// EXPERIMENTAL: As with the rest of this package, events are
-// subject to change.
-type Event struct {
-	// If non-nil, the event has been aborted, meaning
-	// propagation has stopped to other handlers and
-	// the code should stop what it was doing. Emitters
-	// may choose to use this as a signal to adjust their
-	// code path appropriately.
-	Aborted error
-
-	// The data associated with the event. Usually the
-	// original emitter will be the only one to set or
-	// change these values, but the field is exported
-	// so handlers can have full access if needed.
-	// However, this map is not synchronized, so
-	// handlers must not use this map directly in new
-	// goroutines; instead, copy the map to use it in a
-	// goroutine.
-	Data map[string]any
-
-	id     uuid.UUID
-	ts     time.Time
-	name   string
-	origin caddy.Module
-}
-
-func (e Event) ID() uuid.UUID        { return e.id }
-func (e Event) Timestamp() time.Time { return e.ts }
-func (e Event) Name() string         { return e.name }
-func (e Event) Origin() caddy.Module { return e.origin }
-
-// CloudEvent exports event e as a structure that, when
-// serialized as JSON, is compatible with the
-// CloudEvents spec.
-func (e Event) CloudEvent() CloudEvent {
-	dataJSON, _ := json.Marshal(e.Data)
-	return CloudEvent{
-		ID:              e.id.String(),
-		Source:          e.origin.CaddyModule().String(),
-		SpecVersion:     "1.0",
-		Type:            e.name,
-		Time:            e.ts,
-		DataContentType: "application/json",
-		Data:            dataJSON,
-	}
-}
-
-// CloudEvent is a JSON-serializable structure that
-// is compatible with the CloudEvents specification.
-// See https://cloudevents.io.
-type CloudEvent struct {
-	ID              string          `json:"id"`
-	Source          string          `json:"source"`
-	SpecVersion     string          `json:"specversion"`
-	Type            string          `json:"type"`
-	Time            time.Time       `json:"time"`
-	DataContentType string          `json:"datacontenttype,omitempty"`
-	Data            json.RawMessage `json:"data,omitempty"`
-}
-
-// ErrAborted cancels an event.
-var ErrAborted = errors.New("event aborted")
-
 // Handler is a type that can handle events.
 type Handler interface {
-	Handle(context.Context, Event) error
+	Handle(context.Context, caddy.Event) error
 }
 
 // Interface guards
diff --git a/modules/caddyfs/filesystem.go b/modules/caddyfs/filesystem.go
index b2fdcf7a2..2ec43079a 100644
--- a/modules/caddyfs/filesystem.go
+++ b/modules/caddyfs/filesystem.go
@@ -69,11 +69,11 @@ func (xs *Filesystems) Provision(ctx caddy.Context) error {
 		}
 		// register that module
 		ctx.Logger().Debug("registering fs", zap.String("fs", f.Key))
-		ctx.Filesystems().Register(f.Key, f.fileSystem)
+		ctx.FileSystems().Register(f.Key, f.fileSystem)
 		// remember to unregister the module when we are done
 		xs.defers = append(xs.defers, func() {
 			ctx.Logger().Debug("unregistering fs", zap.String("fs", f.Key))
-			ctx.Filesystems().Unregister(f.Key)
+			ctx.FileSystems().Unregister(f.Key)
 		})
 	}
 	return nil
diff --git a/modules/caddyhttp/fileserver/matcher.go b/modules/caddyhttp/fileserver/matcher.go
index 2bc665d4f..b5b4c9f0f 100644
--- a/modules/caddyhttp/fileserver/matcher.go
+++ b/modules/caddyhttp/fileserver/matcher.go
@@ -274,7 +274,7 @@ func celFileMatcherMacroExpander() parser.MacroExpander {
 func (m *MatchFile) Provision(ctx caddy.Context) error {
 	m.logger = ctx.Logger()
 
-	m.fsmap = ctx.Filesystems()
+	m.fsmap = ctx.FileSystems()
 
 	if m.Root == "" {
 		m.Root = "{http.vars.root}"
diff --git a/modules/caddyhttp/fileserver/matcher_test.go b/modules/caddyhttp/fileserver/matcher_test.go
index b6697b9d8..f0ec4b392 100644
--- a/modules/caddyhttp/fileserver/matcher_test.go
+++ b/modules/caddyhttp/fileserver/matcher_test.go
@@ -117,7 +117,7 @@ func TestFileMatcher(t *testing.T) {
 		},
 	} {
 		m := &MatchFile{
-			fsmap:    &filesystems.FilesystemMap{},
+			fsmap:    &filesystems.FileSystemMap{},
 			Root:     "./testdata",
 			TryFiles: []string{"{http.request.uri.path}", "{http.request.uri.path}/"},
 		}
@@ -229,7 +229,7 @@ func TestPHPFileMatcher(t *testing.T) {
 		},
 	} {
 		m := &MatchFile{
-			fsmap:     &filesystems.FilesystemMap{},
+			fsmap:     &filesystems.FileSystemMap{},
 			Root:      "./testdata",
 			TryFiles:  []string{"{http.request.uri.path}", "{http.request.uri.path}/index.php"},
 			SplitPath: []string{".php"},
@@ -273,7 +273,7 @@ func TestPHPFileMatcher(t *testing.T) {
 func TestFirstSplit(t *testing.T) {
 	m := MatchFile{
 		SplitPath: []string{".php"},
-		fsmap:     &filesystems.FilesystemMap{},
+		fsmap:     &filesystems.FileSystemMap{},
 	}
 	actual, remainder := m.firstSplit("index.PHP/somewhere")
 	expected := "index.PHP"
diff --git a/modules/caddyhttp/fileserver/staticfiles.go b/modules/caddyhttp/fileserver/staticfiles.go
index 2b0caecfc..1072d1878 100644
--- a/modules/caddyhttp/fileserver/staticfiles.go
+++ b/modules/caddyhttp/fileserver/staticfiles.go
@@ -186,7 +186,7 @@ func (FileServer) CaddyModule() caddy.ModuleInfo {
 func (fsrv *FileServer) Provision(ctx caddy.Context) error {
 	fsrv.logger = ctx.Logger()
 
-	fsrv.fsmap = ctx.Filesystems()
+	fsrv.fsmap = ctx.FileSystems()
 
 	if fsrv.FileSystem == "" {
 		fsrv.FileSystem = "{http.vars.fs}"