Unorthodox decoration
A bit over a year ago, I shared a post on the Decorator Pattern and how it’s still something of a hidden gem in the Laravel ecosystem. While it hasn’t exactly exploded in popularity since then, I remain convinced of its value. Especially when it comes to improving modularity. It's one of those design patterns that, once it clicks, you wonder how you ever built without it.
This time, though, I want to share a slightly less conventional use of the pattern. It’s a case where the usual rules didn’t quite fit, and the solution needed to be a little inventive.
Traditionally, decorators implement a shared interface and extend or modify specific behavior. Simple enough. But Laravel finds itself in an interesting place: static typing is becoming more common, yet still isn’t consistently enforced at runtime. Especially in core packages or broader ecosystem tools.
And that opens the door to some interesting flexibility. Or, to put it differently: time to embrace a little duck-typing. But before we dive into that, let me walk you through the context.
Backstory
Not long ago, I was working on an application that needed to interact with what was described as a “JSON API.” The air quotes are intentional. You see, while the data was technically JSON, the delivery method was... unique?
Here’s the situation: the API is served through SOAP. XML envelopes and all. But inside the SOAP body, there's a Base64-encoded string. And that string contains your JSON payload.
So we’re looking at a structure like this:
JSON → Base64 → XML (SOAP)
It’s like those Russian nesting dolls; except every layer makes you question your life choices just a little more (lol).
But hey, the real world isn’t always neatly RESTful or elegantly typed. And when things get weird, we adapt.
Incident
After months of wrangling this hybrid SOAP-JSON setup, the application reached its pre-release stage. QA testing kicked off, and things were going smoothly. Until they weren’t.
Reports came in: users couldn’t complete the registration flow. Something was breaking, but the logs weren’t shedding any light.
Time to call in Laravel Telescope.
I inspected the outgoing HTTP requests to the API and saw something "unorthodox":
Request:
[
]
Response:
HTML Response
Not exactly the clarity I was hoping for.
To Telescope’s credit, it doesn't natively process SOAP/XML payloads. And even if it did, here’s what it would show:
<soapenv:Envelope xmlns:soapenv="https://schemas.xmlsoap.org/soap/envelope">
<soapenv:Body>
<paramRequest>U25lYWt5LUJlYWt5IExpa2UgOyk=</paramRequest>
</soapenv:Body>
</soapenv:Envelope>
It’s technically accurate, but not exactly human readable.
Still, since we’re ultimately working with JSON (albeit several layers deep), I was determined to find a way to make Telescope show something more useful.
Solution discovery
I started by digging through the Telescope documentation to see if there was a built-in way to transform request and response payloads. My goal wasn’t to change the data itself; just to display it differently.
I came across the concept of filtering and also discovered the lesser-known afterRecording
hook.
Filtering wasn’t the right fit, but afterRecording
showed promise. I experimented with mutating the IncomingEntry
, hoping to decode and present the payload in a more helpful format.
When I peeked into the internals of ClientRequestWatcher
, I saw that the original Request and Response objects weren’t retained:
public function recordResponse(ResponseReceived $event)
{
if (! Telescope::isRecording()) {
return;
}
Telescope::recordClientRequest(
IncomingEntry::make([
'method' => $event->request->method(),
'uri' => $event->request->url(),
'headers' => $this->headers($event->request->headers()),
'payload' => $this->payload($this->input($event->request)),
'response_status' => $event->response->status(),
'response_headers' => $this->headers($event->response->headers()),
'response' => $this->response($event->response),
'duration' => $this->duration($event->response),
])
->tags([$event->request->toPsrRequest()->getUri()->getHost()])
);
}
That meant I couldn’t simply hook into the afterRecording
callback and grab what I needed. The detailed objects were already gone by the time my code ran.
I also discovered that I could extend ClientRequestWatcher
and register a custom one via the telescope.php
config file. But I tend to reach for inheritance only when I’m out of better options. In this case, I still wanted to preserve modularity. Consistent with the decorator pattern I was championing from the start.
So, I took a step back. Let the idea sit. Sometimes the best ideas don’t come from diving deeper: they surface while you’re looking elsewhere. After letting the problem simmer overnight, I woke up the next morning with a clearer mind.
If the watchers are resolved through the container, then perhaps I could decorate the existing watcher. Wrap it. Extend its behavior. Add the formatting I needed without replacing or subclassing anything directly.
So I checked the source.
protected static function registerWatchers($app)
{
foreach (config('telescope.watchers') as $key => $watcher) {
$watcher = $app->make(is_string($key) ? $key : $watcher, [
'options' => is_array($watcher) ? $watcher : [],
]);
static::$watchers[] = get_class($watcher);
$watcher->register($app);
}
}
Sure enough, the watchers were being resolved through the IoC container. This meant I could bind a decorator for ClientRequestWatcher
, wrap the default one, and enhance it however I wanted: cleanly and without altering any internals.
Decoration elation
I took a deeper look at the ClientRequestWatcher
class and saw that it extends a common Watcher
superclass. Watcher
itself is mostly scaffolding—useful for registration, but not much else.
The challenge? ClientRequestWatcher
doesn’t define a custom interface of its own:
class ClientRequestWatcher extends Watcher
So can we even decorate this class?
You bet!
This is a perfect opportunity to employ a little duck-typing, as the Telescope internals aren't statically typed.
Duck typing is an application of the duck test: "If it walks like a duck and it quacks like a duck, then it must be a duck"... With duck typing, an object is of a given type if it has all methods and properties required by that type.
In short: as long as our object behaves the same, we’re good.
Thanks to Laravel, there's an effortless way to pass through all behavior to the wrapped object: the Illuminate\Support\Traits\ForwardsCalls
trait:
final readonly class UnwrapSoapXml
{
use ForwardsCalls;
public function __construct(private ClientRequestWatcher $watcher) {}
public function __call(string $name, array $arguments): mixed
{
return $this->forwardCallTo($this->watcher, $name, $arguments);
}
}
Et voilĂ ! We have an object that is a ClientRequestWatcher
because it behaves just like one.
Now we just need to enhance one method: recordResponse
. (Well, technically two because we also need to register the overridden method with the Dispatcher
).
Here’s the final version of our decorator:
final readonly class UnwrapSoapXml
{
use ForwardsCalls;
public function __construct(private ClientRequestWatcher $watcher) {}
public function register(Application $app): void
{
$app['events']->listen(ConnectionFailed::class, $this->watcher->recordFailedRequest(...));
$app['events']->listen(ResponseReceived::class, $this->recordResponse(...));
}
public function recordResponse(ResponseReceived $event): void
{
if ($event->request->url() === config('services.ws_endpoint')) {
try {
/** @var \GuzzleHttp\Psr7\Request $request */
$request = $event->request->toPsrRequest();
/** @var \GuzzleHttp\Psr7\Response $response */
$response = $event->response->toPsrResponse();
$data = SoapRequest::unwrap($event->request->body());
$request = $request->withBody(Utils::streamFor(json_encode($data)));
$request = new Request($request);
$request->withData($data);
$data = SoapResponse::unwrap($event->response->body());
$response = $response->withBody(Utils::streamFor(json_encode($data)));
$response = new Response($response);
$response->transferStats = $event->response->transferStats;
$event = new ResponseReceived($request, $response);
} catch (Throwable) {
//
}
}
$this->watcher->recordResponse($event);
}
public function __call(string $name, array $arguments): mixed
{
return $this->forwardCallTo($this->watcher, $name, $arguments);
}
}
We swap out the original $event
object with one that uses our custom JSON-parsed $request
and $response
bodies.
To complete the integration, we bind our decorator in a ServiceProvider
:
final class ServiceProvider extends AggregateServiceProvider
{
public function boot(): void
{
Telescope::hideRequestParameters(['APIGuid']);
}
public function register(): void
{
$this->app->extend(ClientRequestWatcher::class,
static fn (ClientRequestWatcher $watcher) => new UnwrapSoapXml($watcher)
);
}
}
Now, when you open Telescope, you’ll see this instead of a mystery payload:
Request:
{
"APIGuid": "********",
"Action": "SetCustomer",
"Customer": {
"City": "redacted",
"HouseNumber": "redacted",
"LandCodeISO2": "BE",
"Street": "redacted",
"Zipcode": "9000",
"Geboortedatum": "redacted",
"Emailadress": "redacted",
"Name": "Muhammed Sarı",
"Phone": "redacted",
"Company": 0
}
}
Response:
{
"Status": "Not Ok",
"Answer": "JSON does not contain Customer.Emailaddress",
"ErrorString": "Incomplete information in the JSON"
}
Aha! There’s our problem. They introduced a breaking change... without prior warning. Supercalifragilisticexpialidocious.
(No, I did not make a typo myself—the typo was always there until they decided to correct it. Sigh.)
Tangent
This is the primary reason I’m really skeptical about relying too heavily on recorded HTTP tests. When you don’t own the upstream service, you’re vulnerable to surprise changes. You might keep fooling your application into thinking everything’s fine. But in production, reality bites.
Wrap-up
I hope you learned something new! This kind of decorator approach might be unconventional, but it can be a game changer! Especially in modular systems.
If you'd like to learn more about the decorator pattern, the different puzzle pieces used like Laravel's extend
method or have a hands-on example, definitely check out my first blog post "Hands-on decoration".
Join the discussion on X (formerly Twitter)! I'd love to know what you thought about this blog post.
Thanks for reading!