diff --git a/include/libcamera/internal/layer_manager.h b/include/libcamera/internal/layer_manager.h
new file mode 100644
index 000000000000..73ccad01bca0
--- /dev/null
+++ b/include/libcamera/internal/layer_manager.h
@@ -0,0 +1,74 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+/*
+ * Copyright (C) 2025, Ideas On Board Oy
+ *
+ * Layer manager interface
+ */
+
+#pragma once
+
+#include <deque>
+#include <memory>
+#include <set>
+#include <string>
+
+#include <libcamera/base/log.h>
+#include <libcamera/base/span.h>
+
+#include <libcamera/camera.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(LayerManager)
+
+class LayerManager
+{
+public:
+	LayerManager();
+	~LayerManager();
+
+	void bufferCompleted(Request *request, FrameBuffer *buffer);
+	void requestCompleted(Request *request);
+	void disconnected();
+
+	void acquire();
+	void release();
+
+	const ControlInfoMap &controls(const ControlInfoMap &controlInfoMap);
+	const ControlList &properties(const ControlList &properties);
+	const std::set<Stream *> &streams(const std::set<Stream *> &streams);
+
+	void generateConfiguration(Span<const StreamRole> &roles,
+				   CameraConfiguration *config);
+
+	void configure(CameraConfiguration *config);
+
+	void createRequest(uint64_t cookie, Request *request);
+
+	void queueRequest(Request *request);
+
+	void start(const ControlList *controls);
+	void stop();
+
+private:
+	/* Extend the layer with information specific to load-handling */
+	struct LayerLoaded
+	{
+		Layer layer;
+		void *dlHandle;
+	};
+
+	std::unique_ptr<LayerLoaded> createLayer(const std::string &file);
+	std::deque<std::unique_ptr<LayerLoaded>> executionQueue_;
+
+	ControlInfoMap controlInfoMap_;
+	ControlList properties_;
+	std::set<Stream *> streams_;
+};
+
+} /* 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..8fa0ee7d24e6
--- /dev/null
+++ b/include/libcamera/layer.h
@@ -0,0 +1,56 @@
+/* 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>
+
+namespace libcamera {
+
+class CameraConfiguration;
+class ControlInfoMap;
+class ControlList;
+class FrameBuffer;
+class Request;
+class Stream;
+enum class StreamRole;
+
+struct Layer
+{
+	const char *name;
+	int layerAPIVersion;
+
+	void (*init)(const std::string &id);
+
+	void (*bufferCompleted)(Request *, FrameBuffer *);
+	void (*requestCompleted)(Request *);
+	void (*disconnected)();
+
+	void (*acquire)();
+	void (*release)();
+
+	ControlInfoMap::Map (*controls)(ControlInfoMap &);
+	ControlList (*properties)(ControlList &);
+	std::set<Stream *> (*streams)(std::set<Stream *> &);
+
+	void (*generateConfiguration)(Span<const StreamRole> &,
+				      CameraConfiguration *);
+
+	void (*configure)(CameraConfiguration *);
+
+	void (*createRequest)(uint64_t, Request *);
+
+	void (*queueRequest)(Request *);
+
+	void (*start)(const ControlList *);
+	void (*stop)();
+} __attribute__((packed));
+
+} /* 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..dee5e5ac5804
--- /dev/null
+++ b/src/layer/meson.build
@@ -0,0 +1,10 @@
+# SPDX-License-Identifier: CC0-1.0
+
+layer_includes = [
+    libcamera_includes,
+]
+
+layer_install_dir = libcamera_libdir / 'layers'
+
+config_h.set('LAYER_DIR',
+             '"' + get_option('prefix') / layer_install_dir + '"')
diff --git a/src/libcamera/layer_manager.cpp b/src/libcamera/layer_manager.cpp
new file mode 100644
index 000000000000..96d53d4fc75d
--- /dev/null
+++ b/src/libcamera/layer_manager.cpp
@@ -0,0 +1,314 @@
+/* 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(LayerManager)
+
+/**
+ * \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.
+ */
+
+/**
+ * \brief Construct a LayerManager instance
+ *
+ * The LayerManager class is meant be instantiated by the Camera.
+ */
+LayerManager::LayerManager()
+{
+	std::map<std::string, std::unique_ptr<LayerLoaded>> layers;
+
+	/* This returns the number of "modules" successfully loaded */
+	std::function<int(const std::string &)> addDirHandler =
+	[this, &layers](const std::string &file) {
+		std::unique_ptr<LayerManager::LayerLoaded> layer = createLayer(file);
+		if (!layer)
+			return 0;
+
+		LOG(LayerManager, Debug) << "Loaded layer '" << file << "'";
+
+		layers.insert({std::string(layer->layer.name), std::move(layer)});
+
+		return 1;
+	};
+
+	/* User-specified paths take precedence. */
+	const char *layerPaths = utils::secure_getenv("LIBCAMERA_LAYER_PATH");
+	if (layerPaths) {
+		for (const auto &dir : utils::split(layerPaths, ":")) {
+			if (dir.empty())
+				continue;
+
+			utils::addDir(dir.c_str(), 0, addDirHandler);
+		}
+	}
+
+	/*
+	 * 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::addDir(layerBuildPath.c_str(), maxDepth, addDirHandler);
+	}
+
+	/* Finally try to load layers from the installed system path. */
+	utils::addDir(LAYER_DIR, 0, addDirHandler);
+
+	/* Order the layers */
+	/* \todo Document this. First is closer to application, last is closer to libcamera */
+	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())
+				continue;
+
+			executionQueue_.push_back(std::move(it->second));
+		}
+	}
+}
+
+LayerManager::~LayerManager()
+{
+	for (auto &layer : executionQueue_)
+		dlclose(layer->dlHandle);
+}
+
+std::unique_ptr<LayerManager::LayerLoaded> LayerManager::createLayer(const std::string &filename)
+{
+	File file{ filename };
+	if (!file.open(File::OpenModeFlag::ReadOnly)) {
+		LOG(LayerManager, Error) << "Failed to open layer: "
+					 << strerror(-file.error());
+		return nullptr;
+	}
+
+	Span<const uint8_t> data = file.map();
+	int ret = utils::elfVerifyIdent(data);
+	if (ret) {
+		LOG(LayerManager, Error) << "Layer is not an ELF file";
+		return nullptr;
+	}
+
+	Span<const uint8_t> info = utils::elfLoadSymbol(data, "layerInfo");
+	if (info.size() < sizeof(Layer)) {
+		LOG(LayerManager, Error) << "Layer has no valid info";
+		return nullptr;
+	}
+
+	void *dlHandle = dlopen(file.fileName().c_str(), RTLD_LAZY);
+	if (!dlHandle) {
+		LOG(LayerManager, Error)
+			<< "Failed to open layer shared object: "
+			<< dlerror();
+		return nullptr;
+	}
+
+	void *symbol = dlsym(dlHandle, "layerInfo");
+	if (!symbol) {
+		LOG(LayerManager, Error)
+			<< "Failed to load layerInfo from layer shared object: "
+			<< dlerror();
+		dlclose(dlHandle);
+		dlHandle = nullptr;
+		return nullptr;
+	}
+
+	std::unique_ptr<LayerManager::LayerLoaded> layer =
+		std::make_unique<LayerManager::LayerLoaded>();
+	layer->layer = *reinterpret_cast<Layer *>(symbol);
+
+	/* \todo Implement this. It should come from the libcamera version */
+	if (layer->layer.layerAPIVersion != 1) {
+		LOG(LayerManager, Error) << "Layer API version mismatch";
+		return nullptr;
+	}
+
+	/* \todo Validate the layer name. */
+
+	layer->dlHandle = dlHandle;
+
+	return layer;
+}
+
+void LayerManager::bufferCompleted(Request *request, FrameBuffer *buffer)
+{
+	/* Reverse order because this comes from a Signal emission */
+	for (auto it = executionQueue_.rbegin();
+	     it != executionQueue_.rend(); it++) {
+		if ((*it)->layer.bufferCompleted)
+			(*it)->layer.bufferCompleted(request, buffer);
+	}
+}
+
+void LayerManager::requestCompleted(Request *request)
+{
+	/* Reverse order because this comes from a Signal emission */
+	for (auto it = executionQueue_.rbegin();
+	     it != executionQueue_.rend(); it++) {
+		if ((*it)->layer.requestCompleted)
+			(*it)->layer.requestCompleted(request);
+	}
+}
+
+void LayerManager::disconnected()
+{
+	/* Reverse order because this comes from a Signal emission */
+	for (auto it = executionQueue_.rbegin();
+	     it != executionQueue_.rend(); it++) {
+		if ((*it)->layer.disconnected)
+			(*it)->layer.disconnected();
+	}
+}
+
+void LayerManager::acquire()
+{
+	for (std::unique_ptr<LayerManager::LayerLoaded> &layer : executionQueue_)
+		if (layer->layer.acquire)
+			layer->layer.acquire();
+}
+
+void LayerManager::release()
+{
+	for (std::unique_ptr<LayerManager::LayerLoaded> &layer : executionQueue_)
+		if (layer->layer.release)
+			layer->layer.release();
+}
+
+const ControlInfoMap &LayerManager::controls(const ControlInfoMap &controlInfoMap)
+{
+	controlInfoMap_ = controlInfoMap;
+
+	/* \todo Simplify this once ControlInfoMaps become easier to modify */
+	for (std::unique_ptr<LayerManager::LayerLoaded> &layer : executionQueue_) {
+		if (layer->layer.controls) {
+			ControlInfoMap::Map ret = layer->layer.controls(controlInfoMap_);
+			ControlInfoMap::Map map;
+			/* Merge the layer's ret later so that layers can overwrite */
+			for (auto &pair : controlInfoMap_)
+				map.insert(pair);
+			for (auto &pair : ret)
+				map.insert(pair);
+			controlInfoMap_ = ControlInfoMap(std::move(map),
+							 libcamera::controls::controls);
+		}
+	}
+	return controlInfoMap_;
+}
+
+const ControlList &LayerManager::properties(const ControlList &properties)
+{
+	properties_ = properties;
+	for (std::unique_ptr<LayerManager::LayerLoaded> &layer : executionQueue_) {
+		if (layer->layer.properties) {
+			ControlList ret = layer->layer.properties(properties_);
+			properties_.merge(ret, ControlList::MergePolicy::OverwriteExisting);
+		}
+	}
+	return properties_;
+}
+
+const std::set<Stream *> &LayerManager::streams(const std::set<Stream *> &streams)
+{
+	streams_ = streams;
+	for (std::unique_ptr<LayerManager::LayerLoaded> &layer : executionQueue_) {
+		if (layer->layer.streams) {
+			std::set<Stream *> ret = layer->layer.streams(streams_);
+			streams_.insert(ret.begin(), ret.end());
+		}
+	}
+	return streams_;
+}
+
+void LayerManager::generateConfiguration(Span<const StreamRole> &roles,
+					 CameraConfiguration *config)
+{
+	for (std::unique_ptr<LayerManager::LayerLoaded> &layer : executionQueue_)
+		if (layer->layer.generateConfiguration)
+			layer->layer.generateConfiguration(roles, config);
+}
+
+void LayerManager::configure(CameraConfiguration *config)
+{
+	for (std::unique_ptr<LayerManager::LayerLoaded> &layer : executionQueue_)
+		if (layer->layer.configure)
+			layer->layer.configure(config);
+}
+
+void LayerManager::createRequest(uint64_t cookie, Request *request)
+{
+	for (std::unique_ptr<LayerManager::LayerLoaded> &layer : executionQueue_)
+		if (layer->layer.createRequest)
+			layer->layer.createRequest(cookie, request);
+}
+
+void LayerManager::queueRequest(Request *request)
+{
+	for (std::unique_ptr<LayerManager::LayerLoaded> &layer : executionQueue_)
+		if (layer->layer.queueRequest)
+			layer->layer.queueRequest(request);
+}
+
+void LayerManager::start(const ControlList *controls)
+{
+	for (std::unique_ptr<LayerManager::LayerLoaded> &layer : executionQueue_)
+		if (layer->layer.start)
+			layer->layer.start(controls);
+}
+
+void LayerManager::stop()
+{
+	for (std::unique_ptr<LayerManager::LayerLoaded> &layer : executionQueue_)
+		if (layer->layer.stop)
+			layer->layer.stop();
+}
+
+} /* namespace libcamera */
diff --git a/src/libcamera/meson.build b/src/libcamera/meson.build
index 8e2aa921a620..226a94768514 100644
--- a/src/libcamera/meson.build
+++ b/src/libcamera/meson.build
@@ -40,6 +40,7 @@ libcamera_internal_sources = files([
     'ipc_pipe.cpp',
     'ipc_pipe_unixsocket.cpp',
     'ipc_unixsocket.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')
 
