carsdotcom/php-request-class
carsdotcom/php-request-class adds structured request objects to Laravel, letting you wrap input parsing, validation, and typed access in dedicated classes instead of cluttering controllers. Keeps request handling clean, explicit, and easy to test.
This package is a "one class per outbound HTTP call" abstraction: each third-party endpoint gets its own class extending AbstractRequest, with Guzzle doing the transfer and the package handling response caching, structured log files, and request stats for you. Day to day, you spend your time writing small request classes rather than hand-rolling Http:: calls, and you get a per-request log file on disk as a side effect.
Minimal adoption path:
composer require carsdotcom/php-request-class and confirm where log files land (the package config / README covers the log root; most teams point it at storage/logs/requests or a dedicated disk).AbstractRequest for one read-only endpoint. Implement the request shape (getRequestMethod(), getRequestUrl(), headers/body) and, if you want a typed result, parseResponseString()/parseResponseBody() to map JSON into a DTO.[$this->toGuzzle(), $this->response, $outcome, $this->requestStats].Where to look first in the source: AbstractRequest::log() (the docblock describes the override pattern), responseFromCache()/writeResponseToCache() for caching, and postProcess() for where business-rule validation belongs.
If you're adopting v2.0.0, read the Gotchas section before writing your first log() override. Plain consumers that only override getLogFolder() are unaffected; anyone who overrides log(), responseFromCache(), or writeResponseToCache() has audit work to do.
First use case: a GET against a flaky partner API, wrapped so you get caching plus a log file you can hand to support when a customer says "the price was wrong."
One class per endpoint, inputs via constructor. Keep the request class dumb about where the data came from — take typed scalars/DTOs in the constructor, build the query/body in toGuzzle(). Resolve via app(PriceLookupRequest::class, ['sku' => $sku]) or bind a factory. This is what makes them trivially swappable in tests.
Split parsing from validation. parseResponseString()/parseResponseBody() should only turn bytes into a DTO. All "is this response actually usable" logic belongs in postProcess(), which can reject. Since v2.0.0 that rejection lands in the same log entry as the response body, so the exception you throw is now a debugging artifact — give it a real message and populate getExtendedData()/->errors() with the fields that mattered. Assume a support engineer reads it.
Always reject postProcess() with a Throwable. Not a string, not an array, not new RejectedPromise(['error' => ...]). This is best practice generally and load-bearing in v2.0.0 (see Gotchas — a non-serializable rejection value can wipe the entire log entry and mask the real failure).
Cache-hit visibility belongs in log(), not in responseFromCache(). The older pattern — override responseFromCache() and call $this->log(...) manually — still works unchanged in v2.0.0 (no double-write; the automatic call no-ops because responseIsFromCache is already true). But the cleaner shape now is to override log() and call parent::log() first, then append your own cache-hit breadcrumb. See the AbstractRequest::log() docblock for the canonical pattern.
Overriding log() safely. Two rules: call parent::log() (so you inherit the cache-hit guard and the standard Request/Response/stats sections), and never assume $outcome is a success. If you must fully replace the base implementation, replicate the guard explicitly:
protected function log($outcome): void
{
if ($this->responseIsFromCache) {
return;
}
// ... your logic
}
Per-tenant / per-job log routing. getLogFolder() is the sanctioned extension point for putting a request's log next to the job that triggered it (e.g. requests/{tenant}/{date}). It's also the only override most consumers need — keep it that way if you can.
Queued jobs. Request classes compose well inside jobs: catch the rejection, decide retry vs. fail, and use requestStats from the log to see how much time the transfer ate relative to your job's total runtime. Since v2.0.0 the log entry is written after parsing and post-processing, so a job that blows up in postProcess() finally leaves behind a log entry that shows the failure instead of a misleading success.
Testing. Bind a fake request class or stub the Guzzle handler; assert on the returned DTO/exception, not on the transfer. If you do assert on logs, assert on content and parsed sections, not on file counts (see Gotchas).
v2.0.0 changed when log() is called and what it's called with. Previously log() was never invoked on a cache hit — an implicit side effect of the call site. Now it's called on every outcome, cache hit or miss, and the default implementation returns early when $this->responseIsFromCache is true.
Audit every log() override. grep -rn "function log(" app/ — any override that doesn't call parent::log() will now start firing on cache hits. Add if ($this->responseIsFromCache) { return; } unless firing on cache hits is genuinely what you want. This is the single most likely way v2.0.0 breaks your app quietly: duplicate analytics events, duplicate log lines, duplicate metrics increments.
A bad postProcess() rejection can now destroy the whole log entry. LogFile::put()'s array_map(stringify_body, $contents) runs outside its own try/catch, and the rejection value now flows into it. If it's not a Throwable and not cleanly JSON-serializable (resource, closure, circular structure), stringify_body() throws, array_map aborts, and nothing is written — not even the Request/Response section. Worse, the logging exception replaces your real one as the promise's rejection reason. Audit: reject only with Throwable.
Log entries now reflect the real outcome — including data that wasn't logged before. For cache misses the file count is unchanged (still exactly one entry, success or failure), but the entry is deferred until parsing and post-processing have run, and it now carries the exception plus its getExtendedData()/->errors(). This fixes the "looks like an unremarkable success" bug (issue #35). Flip side: review what your exception messages and extended data contain — anything sensitive there is now sitting next to the response body in a file on disk. Scrub tokens/PII and check log retention.
Response bodies can appear twice. For BadResponseException/RequestException-style failures, the body was already inside the exception's stringified form, and log() now also includes the raw $this->response when they differ. Cosmetic, but any tooling or test that parses log files structurally — e.g. asserting an exact number of blank-line-delimited sections — needs rechecking.
Don't signal on file counts. Cache-miss log file counts are unchanged by v2.0.0, and tests asserting "nothing gets logged on parse/postProcess failure" or "log directory unchanged" will need updating based on content, not existence. Also note the automatic log() on a cache hit is a no-op after a manual call, so counting writes won't tell you what happened.
Deferred logging means no entry until the request fully settles. If PHP fatals or the worker is killed during a slow postProcess(), nothing gets written. Keep postProcess() fast and pure; push heavy work downstream.
responseIsFromCache isn't flipped until after responseFromCache() returns — that's precisely why the legacy manual-log pattern still avoids double-writing. Don't move the flip yourself.
Extension points, ranked by safety: getLogFolder() (safe, no v2.0.0 impact) → log() with parent::log() (safe if you inherit the guard) → responseFromCache()/writeResponseToCache() (works, but consider migrating to the log() pattern) → fully replacing log() (audit required, most likely to bite you).
How can I help you explore Laravel packages today?