PluginProbe
404 Solution / 4.3.0
404 Solution v4.3.0
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / core / ServiceContainer.php

ServiceContainer.php in 404 Solution 4.3.0, at includes/core/ServiceContainer.php

347 lines 11.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3
4 if (!defined('ABSPATH')) {
5 exit;
6 }
7
8 /**
9 * Simple dependency injection container for managing service instances.
10 *
11 * This container provides a lightweight alternative to the singleton pattern,
12 * making dependencies explicit and enabling easier testing.
13 *
14 * Usage:
15 * $container = ABJ_404_Solution_ServiceContainer::getInstance();
16 * $service = $container->get('service_name');
17 *
18 * Or use the helper function:
19 * $service = abj_service('service_name');
20 *
21 * The global abj_service() helper is defined in
22 * includes/bootstrap/service-locator.php.
23 */
24 class ABJ_404_Solution_ServiceNotRegisteredException extends RuntimeException {
25
26 /** @var string */
27 private $serviceName;
28
29 /** @var string */
30 private $registrationClass;
31
32 /** @var string */
33 private $hint;
34
35 /**
36 * @param string $serviceName
37 * @param string $registrationClass
38 * @param \Throwable|null $previous
39 */
40 public function __construct($serviceName, $registrationClass, ?\Throwable $previous = null) {
41 $this->serviceName = (string)$serviceName;
42 $this->registrationClass = (string)$registrationClass;
43 $this->hint = 'Register service "' . $this->serviceName . '" in ' . $this->registrationClass
44 . ' or use abj_service_optional() when absence is an intentional bootstrap probe.';
45
46 parent::__construct($this->hint, 0, $previous);
47 }
48
49 /** @return string */
50 public function getServiceName() {
51 return $this->serviceName;
52 }
53
54 /** @return string */
55 public function getRegistrationClass() {
56 return $this->registrationClass;
57 }
58
59 /** @return string */
60 public function getHint() {
61 return $this->hint;
62 }
63 }
64
65 class ABJ_404_Solution_ServiceContainer {
66
67 /**
68 * Singleton instance of the container itself.
69 * Note: The container is a singleton, but the services it manages can have any lifecycle.
70 */
71 /** @var self|null */
72 private static $instance = null;
73
74 /**
75 * Most recent Throwable suppressed by safeGet() / abj_service_optional(), or
76 * null when the last resolution succeeded. Recovered by diagnostic
77 * code via getLastSuppressedError().
78 *
79 * @var \Throwable|null
80 */
81 private static $lastSuppressedError = null;
82
83 /**
84 * Registered services and their factory functions.
85 * @var array<string, callable>
86 */
87 private $services = array();
88
89 /**
90 * Instantiated service instances (for singleton services).
91 * @var array<string, mixed>
92 */
93 private $instances = array();
94
95 /**
96 * When true, registration fills gaps without replacing factories that a
97 * caller already installed before lazy bootstrap. Direct registration
98 * calls outside this guarded mode still replace services.
99 *
100 * @var bool
101 */
102 private $preserveExistingRegistrations = false;
103
104 /**
105 * Private constructor to enforce singleton pattern.
106 */
107 private function __construct() {
108 // Private constructor
109 }
110
111 /**
112 * Get the singleton instance of the container.
113 *
114 * @return ABJ_404_Solution_ServiceContainer
115 */
116 public static function getInstance() {
117 if (self::$instance === null) {
118 self::$instance = new self();
119 }
120 return self::$instance;
121 }
122
123 /**
124 * Register a service with a factory function.
125 *
126 * The factory function receives the container as its first parameter,
127 * allowing it to resolve dependencies.
128 *
129 * @param string $name Service identifier
130 * @param callable $factory Factory function that creates the service
131 * @return void
132 */
133 public function set($name, $factory) {
134 if (!is_callable($factory)) {
135 throw new InvalidArgumentException("Factory for service '$name' must be callable");
136 }
137 if ($this->preserveExistingRegistrations && isset($this->services[$name])) {
138 return;
139 }
140 $this->services[$name] = $factory;
141 // Clear any existing instance when re-registering
142 unset($this->instances[$name]);
143 }
144
145 /** @return void */
146 public function beginPreservingExistingRegistrations(): void {
147 $this->preserveExistingRegistrations = true;
148 }
149
150 /** @return void */
151 public function endPreservingExistingRegistrations(): void {
152 $this->preserveExistingRegistrations = false;
153 }
154
155 /**
156 * Get a service instance.
157 *
158 * Services are lazy-loaded - the factory function is only called
159 * the first time the service is requested.
160 *
161 * @param string $name Service identifier
162 * @return mixed The service instance
163 * @throws Exception if service is not registered
164 */
165 public function get($name) {
166 // Return existing instance if already created
167 if (isset($this->instances[$name])) {
168 return $this->instances[$name];
169 }
170
171 // Check if service is registered
172 if (!isset($this->services[$name])) {
173 throw new Exception("Service '$name' is not registered in the container"); // allow-raw-error: programmer assertion, callers either expect the throw or use safeGet()/abj_service_optional() which catch it
174 }
175
176 // Create the instance using the factory
177 $factory = $this->services[$name];
178 $instance = $factory($this);
179
180 // Store the instance for future requests (singleton behavior)
181 $this->instances[$name] = $instance;
182
183 return $instance;
184 }
185
186 /**
187 * Check if a service is registered.
188 *
189 * @param string $name Service identifier
190 * @return bool
191 */
192 public function has($name) {
193 return isset($this->services[$name]);
194 }
195
196 /**
197 * Clear all services and instances.
198 * Useful for testing.
199 *
200 * @return void
201 */
202 public function clear() {
203 $this->services = array();
204 $this->instances = array();
205 }
206
207 /**
208 * Reset the container singleton instance.
209 * Useful for testing.
210 *
211 * @return void
212 */
213 public static function reset() {
214 self::$instance = null;
215 }
216
217 /**
218 * Non-throwing existence check. Returns true iff the container has a
219 * registered factory for the named service. Bootstraps the container
220 * singleton on demand so callers don't have to.
221 *
222 * @param string $name Service identifier
223 * @return bool
224 */
225 public static function safeHas($name) {
226 $c = self::getInstance();
227 return $c->has($name);
228 }
229
230 /**
231 * Non-throwing service resolution. Returns the resolved instance, or
232 * null if the service isn't registered or the factory raises any
233 * Throwable. Replaces the legacy `try { ServiceContainer::get(...) }
234 * catch { fall back } ` pattern at call sites - the swallow lives
235 * here, in one place.
236 *
237 * On failure the underlying Throwable is preserved two ways:
238 * 1. Full context (class, code, file:line, message) is written to
239 * the PHP error log so production sysadmins see it.
240 * 2. The Throwable instance is captured in self::$lastSuppressedError
241 * so diagnostic surfaces (admin notices, integration tests, the
242 * design-audit M401 fix in c367/368/369) can recover the full
243 * exception chain by calling self::getLastSuppressedError().
244 *
245 * @param string $name Service identifier
246 * @return mixed The service instance, or null on any failure
247 */
248 public static function safeGet($name) {
249 $c = self::getInstance();
250 if (!$c->has($name)) {
251 return null;
252 }
253 try {
254 $result = $c->get($name);
255 self::$lastSuppressedError = null;
256 return $result;
257 } catch (\Throwable $e) {
258 self::recordSuppressedError('ServiceContainer::safeGet(' . $name . ')', $e);
259 return null;
260 }
261 }
262
263 /**
264 * Returns the most recent Throwable that was suppressed by safeGet() or
265 * by the abj_service_optional() helper, or null if the last resolution succeeded.
266 *
267 * Diagnostic code should call this immediately after a null-return from
268 * safeGet()/abj_service_optional() to recover the full exception chain. The wrappers
269 * intentionally return null instead of throwing, but the underlying error
270 * is preserved here for inspection.
271 *
272 * @return \Throwable|null
273 */
274 public static function getLastSuppressedError() {
275 return self::$lastSuppressedError;
276 }
277
278 /**
279 * Reset the suppressed-error capture. Useful between tests and after a
280 * caller has handled a prior failure.
281 *
282 * @return void
283 */
284 public static function clearLastSuppressedError() {
285 self::$lastSuppressedError = null;
286 }
287
288 /**
289 * Internal: capture a suppressed Throwable for getLastSuppressedError()
290 * and emit a fully-contextualised PHP error-log line.
291 *
292 * Centralising the swallow-and-log behaviour here ensures every catch in
293 * this file records the exception class, file:line, code, and message,
294 * not just the message, so the exception chain is recoverable from
295 * production logs alone.
296 *
297 * @param string $context Short identifier for the call site
298 * (e.g. 'ServiceContainer::safeGet(foo)').
299 * @param \Throwable $e The suppressed exception.
300 * @return void
301 */
302 private static function recordSuppressedError($context, \Throwable $e) {
303 self::$lastSuppressedError = $e;
304 abj404_logPhpFallback('service-resolution-fallback', sprintf(
305 '%s suppressed %s (code %s) at %s:%d: %s',
306 $context,
307 get_class($e),
308 (string) $e->getCode(),
309 $e->getFile(),
310 $e->getLine(),
311 $e->getMessage()
312 ));
313 }
314
315 /**
316 * Public seam for the abj_service_optional() helper.
317 * Code outside the class cannot reach private statics, so this delegates
318 * to recordSuppressedError() and is otherwise identical. Not intended
319 * for call sites elsewhere in the codebase: use safeGet() instead.
320 *
321 * @param string $context
322 * @param \Throwable $e
323 * @return void
324 */
325 public static function recordSuppressedErrorPublic($context, \Throwable $e) {
326 self::recordSuppressedError($context, $e);
327 }
328
329 /**
330 * Returns true iff the container singleton has been instantiated AND
331 * at least one service factory has been registered. False during
332 * very early boot (autoload-only) or after `reset()` in tests.
333 *
334 * @return bool
335 */
336 public static function isInitialized() {
337 return self::$instance !== null && self::$instance->services !== array();
338 }
339 }
340
341 // Preserve the historical contract that loading core/ServiceContainer.php
342 // makes the global abj_service() helper available. Many tests require the
343 // container file directly to get that helper without also booting the
344 // production bootstrap.php. Production callers reach the same require via
345 // bootstrap.php; require_once makes either path safe.
346 require_once __DIR__ . '/../bootstrap/service-locator.php';
347