diff --git a/include/libcamera/internal/layer_manager.h b/include/libcamera/internal/layer_manager.h
new file mode 100644
index 000000000000..a6d131962c1a
--- /dev/null
+++ b/include/libcamera/internal/layer_manager.h
@@ -0,0 +1,197 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+/*
+ * Copyright (C) 2025, Ideas On Board Oy
+ *
+ * Layer manager interface
+ */
+
+#pragma once
+
+#include <deque>
+#include <dlfcn.h>
+#include <map>
+#include <memory>
+#include <set>
+#include <string>
+#include <tuple>
+
+#include <libcamera/base/log.h>
+#include <libcamera/base/span.h>
+
+#include <libcamera/camera.h>
+#include <libcamera/control_ids.h>
+#include <libcamera/controls.h>
+#include <libcamera/framebuffer.h>
+#include <libcamera/layer.h>
+#include <libcamera/request.h>
+#include <libcamera/stream.h>
+
+namespace libcamera {
+
+LOG_DECLARE_CATEGORY(LayerLoaded)
+LOG_DECLARE_CATEGORY(LayerController)
+LOG_DECLARE_CATEGORY(LayerManager)
+
+/* Extend the layer with information specific to load-handling */
+struct LayerLoaded {
+	LayerLoaded() = default;
+
+	LayerLoaded(const std::string &file);
+
+	LayerLoaded(LayerLoaded &&other)
+		: info(other.info), vtable(other.vtable),
+		  dlHandle(other.dlHandle), valid(other.valid)
+	{
+		other.dlHandle = nullptr;
+	}
+
+	LayerLoaded &operator=(LayerLoaded &&other)
+	{
+		info = other.info;
+		vtable = other.vtable;
+		dlHandle = other.dlHandle;
+		other.dlHandle = nullptr;
+		valid = other.valid;
+		return *this;
+	}
+
+	~LayerLoaded()
+	{
+		if (dlHandle)
+			dlclose(dlHandle);
+	}
+
+	LayerInfo *info = nullptr;
+	LayerInterface *vtable = nullptr;
+	void *dlHandle = nullptr;
+	bool valid = false;
+
+private:
+	LIBCAMERA_DISABLE_COPY(LayerLoaded)
+};
+
+#define _ARG_PARAMS2(type1, type2) type1 arg1, type2 arg2
+#define _ARG_NAMES2(type1, type2) arg1, arg2
+
+#define _ARG_PARAMS1(type1) type1 arg1
+#define _ARG_NAMES1(type1) arg1
+
+#define _ARG_PARAMS0()
+#define _ARG_NAMES0()
+
+#define _GET_OVERRIDE(_1, _2, _3, NAME, ...) NAME
+
+#define ARG_PARAMS(...) _GET_OVERRIDE("ignored", __VA_ARGS__ __VA_OPT__(,) \
+		    _ARG_PARAMS2, _ARG_PARAMS1, _ARG_PARAMS0)(__VA_ARGS__)
+
+#define ARG_NAMES(...) _GET_OVERRIDE("ignored", __VA_ARGS__ __VA_OPT__(,) \
+		    _ARG_NAMES2, _ARG_NAMES1, _ARG_NAMES0)(__VA_ARGS__)
+
+#define LAYER_INSTANCE_CALL(func, ...) \
+	void func(ARG_PARAMS(__VA_ARGS__)) \
+	{ \
+		if (layer->vtable->func) \
+			layer->vtable->func(closure __VA_OPT__(,) ARG_NAMES(__VA_ARGS__)); \
+	}
+
+struct LayerInstance {
+	LayerInstance(const std::shared_ptr<LayerLoaded> &l)
+		: layer(l)
+	{
+	}
+
+	void init(const std::string &id)
+	{
+		closure = layer->vtable->init(id);
+	}
+
+	void terminate()
+	{
+		layer->vtable->terminate(closure);
+	}
+
+	LAYER_INSTANCE_CALL(bufferCompleted, Request *, FrameBuffer *)
+	LAYER_INSTANCE_CALL(requestCompleted, Request *)
+	LAYER_INSTANCE_CALL(disconnected)
+	LAYER_INSTANCE_CALL(acquire)
+	LAYER_INSTANCE_CALL(release)
+	LAYER_INSTANCE_CALL(configure, const CameraConfiguration *)
+	LAYER_INSTANCE_CALL(createRequest, uint64_t, const Request *)
+	LAYER_INSTANCE_CALL(queueRequest, Request *)
+	LAYER_INSTANCE_CALL(start, ControlList &)
+	LAYER_INSTANCE_CALL(stop)
+
+	ControlInfoMap::Map controls(ControlInfoMap &infoMap)
+	{
+		if (!layer->vtable->controls)
+			return ControlInfoMap::Map();
+		return layer->vtable->controls(closure, infoMap);
+	}
+
+	ControlList properties(ControlList &props)
+	{
+		if (!layer->vtable->properties)
+			return ControlList(controls::controls);
+		return layer->vtable->properties(closure, props);
+	}
+
+	const std::shared_ptr<LayerLoaded> layer;
+	void *closure = nullptr;
+};
+
+class LayerController
+{
+public:
+	LayerController(const Camera *camera, const ControlList &properties,
+			const ControlInfoMap &controlInfoMap,
+			const std::map<std::string, std::shared_ptr<LayerLoaded>> &layers);
+	~LayerController();
+
+	void bufferCompleted(Request *request, FrameBuffer *buffer);
+	void requestCompleted(Request *request);
+	void disconnected();
+
+	void acquire();
+	void release();
+
+	const ControlInfoMap &controls() const { return controls_; }
+	const ControlList &properties() const { return properties_; }
+
+	void configure(const CameraConfiguration *config,
+		       const ControlInfoMap &controlInfoMap);
+
+	void createRequest(uint64_t cookie, const Request *request);
+
+	void queueRequest(Request *request);
+
+	ControlList *start(const ControlList *controls);
+	void stop();
+
+private:
+	void updateProperties(const ControlList &properties);
+	void updateControls(const ControlInfoMap &controlInfoMap);
+
+	std::deque<std::unique_ptr<LayerInstance>> executionQueue_;
+
+	ControlInfoMap controls_;
+	ControlList properties_;
+
+	ControlList startControls_ = ControlList(controls::controls);
+};
+
+class LayerManager
+{
+public:
+	LayerManager();
+	~LayerManager() = default;
+
+	std::unique_ptr<LayerController>
+	createController(const Camera *camera,
+			 const ControlList &properties,
+			 const ControlInfoMap &controlInfoMap) const;
+
+private:
+	std::map<std::string, std::shared_ptr<LayerLoaded>> layers_;
+};
+
+} /* namespace libcamera */
diff --git a/include/libcamera/internal/meson.build b/include/libcamera/internal/meson.build
index 690f5c5ec9f6..20e6c295601f 100644
--- a/include/libcamera/internal/meson.build
+++ b/include/libcamera/internal/meson.build
@@ -29,6 +29,7 @@ libcamera_internal_headers = files([
     'ipa_proxy.h',
     'ipc_pipe.h',
     'ipc_unixsocket.h',
+    'layer_manager.h',
     'mapped_framebuffer.h',
     'matrix.h',
     'media_device.h',
diff --git a/include/libcamera/layer.h b/include/libcamera/layer.h
new file mode 100644
index 000000000000..4ab5024a7de2
--- /dev/null
+++ b/include/libcamera/layer.h
@@ -0,0 +1,54 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+/*
+ * Copyright (C) 2025, Ideas On Board Oy
+ *
+ * Layer interface
+ */
+
+#pragma once
+
+#include <set>
+#include <stdint.h>
+
+#include <libcamera/base/span.h>
+
+#include <libcamera/controls.h>
+
+namespace libcamera {
+
+class CameraConfiguration;
+class FrameBuffer;
+class Request;
+class Stream;
+enum class StreamRole;
+
+struct LayerInfo {
+	const char *name;
+	int layerAPIVersion;
+};
+
+struct LayerInterface {
+	void *(*init)(const std::string &);
+	void (*terminate)(void *);
+
+	void (*bufferCompleted)(void *, Request *, FrameBuffer *);
+	void (*requestCompleted)(void *, Request *);
+	void (*disconnected)(void *);
+
+	void (*acquire)(void *);
+	void (*release)(void *);
+
+	ControlInfoMap::Map (*controls)(void *, ControlInfoMap &);
+	ControlList (*properties)(void *, ControlList &);
+
+	void (*configure)(void *, const CameraConfiguration *);
+
+	void (*createRequest)(void *, uint64_t, const Request *);
+
+	void (*queueRequest)(void *, Request *);
+
+	void (*start)(void *, ControlList &);
+	void (*stop)(void *);
+};
+
+} /* namespace libcamera */
diff --git a/include/libcamera/meson.build b/include/libcamera/meson.build
index 30ea76f9470a..552af112abb5 100644
--- a/include/libcamera/meson.build
+++ b/include/libcamera/meson.build
@@ -11,6 +11,7 @@ libcamera_public_headers = files([
     'framebuffer.h',
     'framebuffer_allocator.h',
     'geometry.h',
+    'layer.h',
     'logging.h',
     'orientation.h',
     'pixel_format.h',
diff --git a/src/layer/meson.build b/src/layer/meson.build
new file mode 100644
index 000000000000..45dded512a13
--- /dev/null
+++ b/src/layer/meson.build
@@ -0,0 +1,14 @@
+# SPDX-License-Identifier: CC0-1.0
+
+layer_includes = [
+    libcamera_includes,
+]
+
+layer_install_dir = libcamera_libdir / 'layer'
+
+config_h.set('LAYER_DIR',
+             '"' + get_option('prefix') / layer_install_dir + '"')
+
+layers_env = environment()
+layers_env.set('LIBCAMERA_LAYER_PATH', meson.current_build_dir())
+meson.add_devenv(layers_env)
diff --git a/src/libcamera/layer.cpp b/src/libcamera/layer.cpp
new file mode 100644
index 000000000000..88372096a136
--- /dev/null
+++ b/src/libcamera/layer.cpp
@@ -0,0 +1,178 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+/*
+ * Copyright (C) 2025, Ideas On Board Oy
+ *
+ * Layer interface
+ */
+
+#include <libcamera/layer.h>
+
+/**
+ * \file layer.h
+ * \brief Layer interface
+ *
+ * Layers are a construct that lives in between the application and libcamera.
+ * They are hooked into select calls to and from Camera, and each one is
+ * executed in order.
+ *
+ * \todo Expand on this, and decide on more concrete naming like module vs implementation
+ */
+
+namespace libcamera {
+
+/**
+ * \struct LayerInfo
+ * \brief Information about a Layer implementation
+ *
+ * This struct gives information about a layer implementation, such as name and
+ * API version. It must be exposed, named 'layerInfo', by the layer
+ * implementation shared object to identify itself to the LayerManager.
+ */
+
+/**
+ * \var LayerInfo::name
+ * \brief Name of the Layer module
+ */
+
+/**
+ * \var LayerInfo::layerAPIVersion
+ * \brief API version of the Layer implementation
+ */
+
+/**
+ * \struct LayerInterface
+ * \brief The function table of the Layer implementation
+ *
+ * This struct is the function table of a layer implementation. Any functions
+ * that the layer implements should be filled in here, and any functions that
+ * are not implemented must be set to nullptr. This struct, named
+ * 'layerInterface', must be exposed by the layer implementation shared object.
+ */
+
+/**
+ * \var LayerInterface::init
+ * \brief Initialize the layer
+ * \param[in] name Name of the camera
+ *
+ * This function is a required function for layer implementations.
+ *
+ * This function is called on Camera construction, and is where the layer
+ * implementation should allocated and initialize its closure and anything else
+ * required to run.
+ *
+ * \return A closure
+ */
+
+/**
+ * \var LayerInterface::terminate
+ * \brief Terminate the layer
+ * \param[in] closure The closure that was allocated at init()
+ *
+ * This function is a required function for layer implementations.
+ *
+ * This function is called on Camera deconstruction, and is where the layer
+ * should free its closure and anything else that was allocated at
+ * initialization.
+ */
+
+/**
+ * \var LayerInterface::bufferCompleted
+ * \brief Hook for Camera::bufferCompleted
+ * \param[in] closure The closure of the layer
+ */
+
+/**
+ * \var LayerInterface::requestCompleted
+ * \brief Hook for Camera::requestCompleted
+ * \param[in] closure The closure of the layer
+ */
+
+/**
+ * \var LayerInterface::disconnected
+ * \brief Hook for Camera::disconnected
+ * \param[in] closure The closure of the layer
+ */
+
+/**
+ * \var LayerInterface::acquire
+ * \brief Hook for Camera::acquire
+ * \param[in] closure The closure of the layer
+ */
+
+/**
+ * \var LayerInterface::release
+ * \brief Hook for Camera::release
+ * \param[in] closure The closure of the layer
+ */
+
+/**
+ * \var LayerInterface::controls
+ * \brief Declare the controls supported by the Layer
+ * \param[in] closure The closure of the layer
+ * \param[in] controlInfoMap The cumulative ControlInfoMap of supported controls of the Camera and any previous layers
+ *
+ * This function is for the layer implementation to declare the controls that
+ * it supports. This will be called by the LayerManager at Camera::init() time
+ * (after LayerInterface::init()), and at Camera::configure() time. The latter
+ * gives a chance for the controls to be updated if the configuration changes
+ * them.
+ *
+ * The controls that are returned by this function will overwrite any
+ * duplicates that were in the input parameter controls.
+ *
+ * \return The additional controls that this Layer implements
+ */
+
+/**
+ * \var LayerInterface::properties
+ * \brief Declare the properties supported by the Layer
+ * \param[in] closure The closure of the layer
+ * \param[in] controlList The cumulative properties of the Camera and any previous layers
+ *
+ * This function is for the layer implementation to declare the properies that
+ * it wants to declare. This will be called by the LayerManager once at
+ * Camera::init() time (after LayerInterface::init(), and before
+ * LayerInterface::controls()).
+ *
+ * The properties that are returned by this function will overwrite any
+ * duplicates that were in the input parameter properties.
+ *
+ * \return The additional properties that this Layer declares
+ */
+
+/**
+ * \var LayerInterface::configure
+ * \brief Hook for Camera::configure
+ * \param[in] closure The closure of the layer
+ * \param[in] cameraConfiguration The camera configuration
+ */
+
+/**
+ * \var LayerInterface::createRequest
+ * \brief Hook for Camera::createRequest
+ * \param[in] closure The closure of the layer
+ * \param[in] cookie An opaque cookie for the application
+ * \param[in] request The request that was just created by the Camera
+ */
+
+/**
+ * \var LayerInterface::queueRequest
+ * \brief Hook for Camera::queueRequest
+ * \param[in] closure The closure of the layer
+ * \param[in] request The request that was queued
+ */
+
+/**
+ * \var LayerInterface::start
+ * \brief Hook for Camera::start
+ * \param[in] closure The closure of the layer
+ * \param[in] controls The controls to be set before starting capture
+ */
+
+/**
+ * \var LayerInterface::stop
+ * \brief Hook for Camera::stop
+ * \param[in] closure The closure of the layer
+ */
+
+} /* namespace libcamera */
diff --git a/src/libcamera/layer_manager.cpp b/src/libcamera/layer_manager.cpp
new file mode 100644
index 000000000000..08fe4dcbb86c
--- /dev/null
+++ b/src/libcamera/layer_manager.cpp
@@ -0,0 +1,507 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+/*
+ * Copyright (C) 2025, Ideas On Board Oy
+ *
+ * Layer manager
+ */
+
+#include "libcamera/internal/layer_manager.h"
+
+#include <algorithm>
+#include <dirent.h>
+#include <dlfcn.h>
+#include <map>
+#include <memory>
+#include <set>
+#include <string.h>
+#include <string>
+#include <sys/types.h>
+
+#include <libcamera/base/file.h>
+#include <libcamera/base/log.h>
+#include <libcamera/base/utils.h>
+#include <libcamera/base/span.h>
+
+#include <libcamera/control_ids.h>
+#include <libcamera/layer.h>
+
+#include "libcamera/internal/utils.h"
+
+/**
+ * \file layer_manager.h
+ * \brief Layer manager
+ */
+
+namespace libcamera {
+
+LOG_DEFINE_CATEGORY(LayerLoaded)
+LOG_DEFINE_CATEGORY(LayerController)
+LOG_DEFINE_CATEGORY(LayerManager)
+
+/**
+ * \class LayerLoaded
+ * \brief A wrapper class for a Layer shared object that has been loaded
+ *
+ * This class wraps a Layer shared object that has been loaded, managing the
+ * lifetime and management of the dlopened handle as well as organizing access
+ * to the function table and layer info.
+ */
+
+/**
+ * \var LayerLoaded::info
+ * \brief Information about the Layer
+ */
+
+/**
+ * \var LayerLoaded::vtable
+ * \brief The function table of the layer
+ */
+
+/**
+ * \var LayerLoaded::dlHandle
+ * \brief The handle as returned by dlopen for the layer shared object
+ */
+
+/**
+ * \var LayerLoaded::valid
+ * \brief Whether or not the loaded layer is valid
+ *
+ * Instances that failed to load due to error, or that were constructed with no
+ * parameters will be invalid.
+ */
+
+/**
+ * \brief Load a Layer from a shared object file
+ */
+LayerLoaded::LayerLoaded(const std::string &filename)
+{
+	File file{ filename };
+	if (!file.open(File::OpenModeFlag::ReadOnly)) {
+		LOG(LayerLoaded, Error) << "Failed to open layer: "
+					<< strerror(-file.error());
+		return;
+	}
+
+	Span<const uint8_t> data = file.map();
+	int ret = utils::elfVerifyIdent(data);
+	if (ret) {
+		LOG(LayerLoaded, Error) << "Layer is not an ELF file";
+		return;
+	}
+
+	Span<const uint8_t> layerInfoSym = utils::elfLoadSymbol(data, "layerInfo");
+	if (layerInfoSym.size() < sizeof(LayerInfo)) {
+		LOG(LayerLoaded, Error) << "Layer has no valid layerInfoSym";
+		return;
+	}
+
+	void *dlh = dlopen(file.fileName().c_str(), RTLD_LAZY);
+	if (!dlh) {
+		LOG(LayerLoaded, Error)
+			<< "Failed to open layer shared object: "
+			<< dlerror();
+		return;
+	}
+
+	/* No need to dlclose as the deconstructor will handle it */
+
+	void *layerInfoDl = dlsym(dlh, "layerInfo");
+	if (!layerInfoDl) {
+		LOG(LayerLoaded, Error)
+			<< "Failed to load layerInfo from layer shared object: "
+			<< dlerror();
+		return;
+	}
+
+	void *vtableSym = dlsym(dlh, "layerInterface");
+	if (!vtableSym) {
+		LOG(LayerLoaded, Error)
+			<< "Failed to load layerInterface from layer shared object: "
+			<< dlerror();
+		return;
+	}
+
+	info = static_cast<LayerInfo *>(layerInfoDl);
+	vtable = static_cast<LayerInterface *>(vtableSym);
+	dlHandle = dlh;
+
+	/* \todo Implement this. It should come from the libcamera version */
+	if (info->layerAPIVersion != 1) {
+		LOG(LayerLoaded, Error) << "Layer '" << info->name
+					<< "' API version mismatch";
+		return;
+	}
+
+	/* \todo Document these requirements */
+	if (!vtable->init) {
+		LOG(LayerLoaded, Error) << "Layer '" << info->name
+					<< "' doesn't implement init";
+		return;
+	}
+
+	/* \todo Document these requirements */
+	if (!vtable->terminate) {
+		LOG(LayerLoaded, Error) << "Layer '" << info->name
+					<< "' doesn't implement terminate";
+		return;
+	}
+
+	/* \todo Validate the layer name. */
+
+	valid = true;
+
+	return;
+}
+
+/**
+ * \fn LayerLoaded::LayerLoaded(LayerLoaded &&other)
+ * \brief Move constructor
+ */
+
+/**
+ * \fn LayerLoaded &LayerLoaded::operator=(LayerLoaded &&other)
+ * \brief Move assignment operator
+ */
+
+/**
+ * \class LayerController
+ * \brief Per-Camera instance of a layer manager
+ *
+ * Conceptually this class is an instantiation of the LayerManager for each
+ * Camera instance. It contains the closure for of each layer specific to the
+ * Camera, as well as the queue of layers to execute for each Camera.
+ */
+
+/**
+ * \brief Initialize the Layers
+ * \param[in] camera The Camera for whom to initialize layers
+ * \param[in] properties The Camera properties
+ * \param[in] controlInfoMap The Camera controls
+ * \param[in] layers Map of available layers
+ *
+ * This is called by the Camera at construction time via
+ * LayerManager::createController. The LayerManager feeds the list of layers
+ * that are available, and the LayerController can then create its own
+ * execution queue and initialize all the layers for its Camera.
+ *
+ * \a properties and \a controlInfoMap are passed in so that the Layers can
+ * modify them, although they will be cached in an internal copy that can be
+ * efficiently returned at properties() and controls(), respectively.
+ */
+LayerController::LayerController(const Camera *camera,
+				 const ControlList &properties,
+				 const ControlInfoMap &controlInfoMap,
+				 const std::map<std::string, std::shared_ptr<LayerLoaded>> &layers)
+{
+	/* Order the layers */
+	/* \todo Document this. First is closer to application, last is closer to libcamera */
+	/* \todo Get this from configuration file */
+	const char *layerList = utils::secure_getenv("LIBCAMERA_LAYERS_ENABLE");
+	if (layerList) {
+		for (const auto &layerName : utils::split(layerList, ",")) {
+			if (layerName.empty())
+				continue;
+
+			const auto &it = layers.find(layerName);
+			if (it == layers.end()) {
+				LOG(LayerController, Warning)
+					<< "Requested layer '" << layerName
+					<< "' not found";
+				continue;
+			}
+
+			executionQueue_.emplace_back(std::make_unique<LayerInstance>(it->second));
+		}
+	}
+
+	for (std::unique_ptr<LayerInstance> &layer : executionQueue_)
+		layer->init(camera->id());
+
+	/*
+	 * We need to iterate over the layers individually to merge all of
+	 * their controls, so we'll factor out updateControls() as it needs to be
+	 * run again at configure().
+	 */
+	updateProperties(properties);
+	updateControls(controlInfoMap);
+}
+
+/**
+ * \brief Terminate the Layers
+ *
+ * This is called by the Camera at deconstruction time. The LayerController
+ * instructs all Layer instances to release the resources that they allocated
+ * for this specific \a camera.
+ */
+LayerController::~LayerController()
+{
+	for (std::unique_ptr<LayerInstance> &layer : executionQueue_)
+		layer->terminate();
+}
+
+/**
+ * \brief Hook for Camera::bufferCompleted
+ * \param[in] request The request whose buffer completed
+ * \param[in] buffer The buffer that completed
+ */
+void LayerController::bufferCompleted(Request *request, FrameBuffer *buffer)
+{
+	/* Reverse order because this comes from a Signal emission */
+	for (auto it = executionQueue_.rbegin();
+	     it != executionQueue_.rend(); it++) {
+		(*it)->bufferCompleted(request, buffer);
+	}
+}
+
+/**
+ * \brief Hook for Camera::requestCompleted
+ * \param[in] request The request that completed
+ */
+void LayerController::requestCompleted(Request *request)
+{
+	/* Reverse order because this comes from a Signal emission */
+	for (auto it = executionQueue_.rbegin();
+	     it != executionQueue_.rend(); it++) {
+		(*it)->requestCompleted(request);
+	}
+}
+
+/**
+ * \brief Hook for Camera::disconnected
+ */
+void LayerController::disconnected()
+{
+	/* Reverse order because this comes from a Signal emission */
+	for (auto it = executionQueue_.rbegin();
+	     it != executionQueue_.rend(); it++) {
+		(*it)->disconnected();
+	}
+}
+
+/**
+ * \brief Hook for Camera::acquire
+ */
+void LayerController::acquire()
+{
+	for (std::unique_ptr<LayerInstance> &layer : executionQueue_)
+		layer->acquire();
+}
+
+/**
+ * \brief Hook for Camera::release
+ */
+void LayerController::release()
+{
+	for (std::unique_ptr<LayerInstance> &layer : executionQueue_)
+		layer->release();
+}
+
+/**
+ * \fn LayerController::controls
+ * \brief Hook for Camera::controls
+ * \return A ControlInfoMap that merges the Camera's controls() with the ones
+ * declared by the layers
+ */
+
+/**
+ * \fn LayerController::properties
+ * \brief Hook for Camera::properties
+ * \return A properties list that merges the Camera's properties() with the
+ * ones declared by the layers
+ */
+
+void LayerController::updateProperties(const ControlList &properties)
+{
+	ControlList props = properties;
+	for (std::unique_ptr<LayerInstance> &layer : executionQueue_) {
+		ControlList ret = layer->properties(props);
+		props.merge(ret, ControlList::MergePolicy::OverwriteExisting);
+	}
+	properties_ = props;
+}
+
+void LayerController::updateControls(const ControlInfoMap &controlInfoMap)
+{
+	ControlInfoMap infoMap = controlInfoMap;
+	/* \todo Simplify this once ControlInfoMaps become easier to modify */
+	for (std::unique_ptr<LayerInstance> &layer : executionQueue_) {
+		ControlInfoMap::Map ret = layer->controls(infoMap);
+		ControlInfoMap::Map map;
+		/* Merge the layer's ret first since insert doesn't overwrite */
+		map.insert(ret.begin(), ret.end());
+		map.insert(infoMap.begin(), infoMap.end());
+		infoMap = ControlInfoMap(std::move(map),
+					 libcamera::controls::controls);
+	}
+	controls_ = infoMap;
+}
+
+/**
+ * \brief Hook for Camera::configure
+ * \param[in] config The configuration
+ * \param[in] controlInfoMap The ControlInfoMap of the controls that \a camera supports
+ *
+ * \a controlInfoMap is passed in as this is a potential point where the limits
+ * of controls could change, so this gives a chance for the Layers to update
+ * the ControlInfoMap that will be returned by LayerController::controls().
+ */
+void LayerController::configure(const CameraConfiguration *config,
+				const ControlInfoMap &controlInfoMap)
+{
+	for (std::unique_ptr<LayerInstance> &layer : executionQueue_)
+		layer->configure(config);
+
+	updateControls(controlInfoMap);
+}
+
+/**
+ * \brief Hook for Camera::createRequest
+ * \param[in] cookie An opaque cookie for the application
+ * \param[in] request The request that was created
+ */
+void LayerController::createRequest(uint64_t cookie, const Request *request)
+{
+	for (std::unique_ptr<LayerInstance> &layer : executionQueue_)
+		layer->createRequest(cookie, request);
+}
+
+/**
+ * \brief Hook for Camera::queueRequest
+ * \param[in] request The request that is being queued
+ */
+void LayerController::queueRequest(Request *request)
+{
+	for (std::unique_ptr<LayerInstance> &layer : executionQueue_)
+		layer->queueRequest(request);
+}
+
+/**
+ * \brief Hook for Camera::start
+ * \param[in] controls The controls to be applied before starting the capture
+ * \return A ControlList that merges controls set by the layers and \a controls
+ */
+ControlList *LayerController::start(const ControlList *controls)
+{
+	if (controls) {
+		/* Clear any leftover start controls from a previous run */
+		startControls_.clear();
+		startControls_.merge(*controls);
+	}
+
+	for (std::unique_ptr<LayerInstance> &layer : executionQueue_)
+		layer->start(startControls_);
+
+	return &startControls_;
+}
+
+/**
+ * \brief Hook for Camera::stop
+ */
+void LayerController::stop()
+{
+	for (std::unique_ptr<LayerInstance> &layer : executionQueue_)
+		layer->stop();
+}
+
+/**
+ * \class LayerManager
+ * \brief Layer manager
+ *
+ * The Layer manager discovers layer implementations from disk, and creates
+ * execution queues for every function that is implemented by each layer and
+ * executes them. A layer is a layer that sits between libcamera and the
+ * application, and hooks into the public Camera interface.
+ *
+ * The LayerManager itself is instantiated by the CameraManager, and each
+ * Camera interacts with the LayerManager by passing itself it. The
+ * LayerManager internally maps each Camera to a list of Layer instances that
+ * it calls sequentially for each hook.
+ */
+
+/**
+ * \brief Construct a LayerManager instance
+ *
+ * The LayerManager class is meant be instantiated by the CameraManager.
+ *
+ * This function simply loads all available layers and stores them. The
+ * LayerController is responsible for organizing them into queues to be
+ * executed and for managing closures, for each Camera that they belong to.
+ */
+LayerManager::LayerManager()
+{
+	/* This is so that we can capture it in the lambda below */
+	std::map<std::string, std::shared_ptr<LayerLoaded>> &layers = layers_;
+
+	/* \todo Implement built-in layers */
+
+	/* This returns the number of "modules" successfully loaded */
+	std::function<int(const std::string &)> soHandler =
+	[this, &layers](const std::string &file) {
+		std::shared_ptr<LayerLoaded> layer = std::make_shared<LayerLoaded>(file);
+		if (!layer->valid)
+			return 0;
+
+		LOG(LayerManager, Debug) << "Loaded layer '" << file << "'";
+
+		auto [it, inserted] =
+			layers.try_emplace(std::string(layer->info->name),
+					   std::move(layer));
+		if (!inserted)
+			LOG(LayerManager, Warning)
+				<< "Not adding duplicate layer '"
+				<< layer->info->name << "'";
+
+		return 1;
+	};
+
+	/* User-specified paths take precedence. */
+	/* \todo Document this */
+	const char *layerPaths = utils::secure_getenv("LIBCAMERA_LAYER_PATH");
+	if (layerPaths) {
+		for (const auto &dir : utils::split(layerPaths, ":")) {
+			if (dir.empty())
+				continue;
+
+			/*
+			 * \todo Move the shared objects into one directory
+			 * instead of each in their own subdir
+			 */
+			utils::findSharedObjects(dir.c_str(), 1, soHandler);
+		}
+	}
+
+	/*
+	 * When libcamera is used before it is installed, load layers from the
+	 * same build directory as the libcamera library itself.
+	 */
+	std::string root = utils::libcameraBuildPath();
+	if (!root.empty()) {
+		std::string layerBuildPath = root + "src/layer";
+		constexpr int maxDepth = 2;
+
+		LOG(LayerManager, Info)
+			<< "libcamera is not installed. Adding '"
+			<< layerBuildPath << "' to the layer search path";
+
+		utils::findSharedObjects(layerBuildPath.c_str(), maxDepth, soHandler);
+	}
+
+	/* Finally try to load layers from the installed system path. */
+	utils::findSharedObjects(LAYER_DIR, 1, soHandler);
+}
+
+/**
+ * \brief Create a LayerController instance
+ * \param[in] camera The Camera instance for whom to create a LayerController
+ * \param[in] properties The Camera properties
+ * \param[in] controlInfoMap The Camera controls
+ */
+std::unique_ptr<LayerController>
+LayerManager::createController(const Camera *camera,
+			       const ControlList &properties,
+			       const ControlInfoMap &controlInfoMap) const
+{
+	return std::make_unique<LayerController>(camera, properties, controlInfoMap, layers_);
+}
+
+} /* namespace libcamera */
diff --git a/src/libcamera/meson.build b/src/libcamera/meson.build
index 4382d4fa8d38..79c8dfafe870 100644
--- a/src/libcamera/meson.build
+++ b/src/libcamera/meson.build
@@ -40,6 +40,8 @@ libcamera_internal_sources = files([
     'ipc_pipe.cpp',
     'ipc_pipe_unixsocket.cpp',
     'ipc_unixsocket.cpp',
+    'layer.cpp',
+    'layer_manager.cpp',
     'mapped_framebuffer.cpp',
     'matrix.cpp',
     'media_device.cpp',
diff --git a/src/meson.build b/src/meson.build
index 8eb8f05b362f..37368b01cbf2 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -63,6 +63,7 @@ subdir('libcamera')
 
 subdir('android')
 subdir('ipa')
+subdir('layer')
 
 subdir('apps')
 
