A Laravel package foundation for detecting, aggregating, and normalizing HTTP 500 errors.
ashita-planning/laravel-error-monitor is a Laravel package for a laravel package foundation for detecting, aggregating, and normalizing http 500 errors..
It currently has 0 GitHub stars and 112 downloads on Packagist (latest version v1.1.0).
Install it with composer require ashita-planning/laravel-error-monitor.
Discover more Laravel packages by ashita-planning
or browse all Laravel packages to compare alternatives.
Last updated
ashita-planning/laravel-error-monitor is a Laravel package foundation for detecting, normalizing, and daily-aggregating HTTP 500 errors from application and web-server logs.
| | | | --- | --- | | Installation | The three packages, in order | | XServer logs | Paths, the file-date trap, missing files | | GitHub issues | Token permissions, duplicate prevention | | Scheduling | When to run it, exit codes, retention | | The issue agent | Setup and the first smoke test | | Security | What is masked, what is deliberately kept | | Troubleshooting | Symptom to cause | | Operations checklist | Before the first run, and monthly |
CI verifies that the package code runs on all four major versions. That is a statement about code compatibility, not an endorsement of any given framework release: Laravel 10 and 11 have published security advisories, so the compatibility matrix installs them with Composer's advisory blocking disabled for that step only. Choosing a framework version that is safe to run in production remains the host application's decision. A separate CI job audits the newest resolvable dependency set under the normal Composer policy.
composer require ashita-planning/laravel-error-monitor
php artisan vendor:publish --provider="Apkk\LaravelErrorMonitor\ErrorMonitorServiceProvider" --tag=error-monitor-config
php artisan migrate
Laravel discovers the service provider automatically. To verify the installation:
php artisan error-monitor:status
The migrations are loaded from the package. Publish them with
--tag=error-monitor-migrations if you prefer to own them in your application.
All environment variables use the ERROR_MONITOR_ prefix.
| Key | Environment variable | Default | Purpose |
| --- | --- | --- | --- |
| enabled | ERROR_MONITOR_ENABLED | true | Master switch. While disabled, migrations are not loaded and no driver is resolved. |
| environment | ERROR_MONITOR_ENVIRONMENT | APP_ENV | Environment recorded on every event. |
| timezone | ERROR_MONITOR_TIMEZONE | app.timezone | Timezone of the daily bucket. |
| laravel_log_path | ERROR_MONITOR_LARAVEL_LOG_PATH | storage/logs/laravel.log | Where the Laravel logs live. May be the directory or any file inside it; the patterns are applied to the directory either way. |
| laravel_log_patterns | ERROR_MONITOR_LARAVEL_LOG_PATTERNS | laravel.log,laravel-*.log | File patterns of the log source. Covers the single and daily channels. |
| laravel_log_levels | ERROR_MONITOR_LARAVEL_LOG_LEVELS | ERROR,CRITICAL,ALERT,EMERGENCY | Monolog levels read as failures. The HTTP status, not the level, decides what is stored. |
| laravel_log_max_files | ERROR_MONITOR_LARAVEL_LOG_MAX_FILES | 31 | Newest files analyzed per run; 0 means no limit. |
| laravel_log_max_bytes | ERROR_MONITOR_LARAVEL_LOG_MAX_BYTES | 536870912 | Larger files are skipped; 0 means no limit. |
| apache_access_log_path | ERROR_MONITOR_APACHE_ACCESS_LOG_PATH | /var/log/apache2 | Where the Apache access logs live. Directory or any file inside it. |
| apache_access_log_patterns | ERROR_MONITOR_APACHE_ACCESS_LOG_PATTERNS | access.log,access_log,access.log.*,… | File patterns, including the rotated and .gz generations. |
| apache_access_status_codes | ERROR_MONITOR_APACHE_ACCESS_STATUS_CODES | 500-599 | Which statuses become events. Ranges and single codes, e.g. 500-599 or 500,502,503. |
| apache_access_patterns | – | [] | Extra regexes with named groups for a custom LogFormat, tried before the built-in formats. |
| apache_error_log_path | ERROR_MONITOR_APACHE_ERROR_LOG_PATH | /var/log/apache2 | Where the Apache error logs live. Directory or any file inside it. |
| apache_error_log_patterns | ERROR_MONITOR_APACHE_ERROR_LOG_PATTERNS | error.log,error_log,error.log.*,… | File patterns, including the rotated and .gz generations. |
| apache_error_log_levels | ERROR_MONITOR_APACHE_ERROR_LOG_LEVELS | error,crit,alert,emerg,warn | Apache levels read as failures. warn is included because mod_fcgid reports a read timeout at that level. |
| correlation.enabled | ERROR_MONITOR_CORRELATION_ENABLED | true | Whether Apache 5xx are matched to Laravel exceptions. |
| correlation.window_seconds | ERROR_MONITOR_CORRELATION_WINDOW_SECONDS | 5 | How far apart the two entries may be. Both logs describe the same request, so this is seconds — not the much wider analysis.context_*_seconds. |
| results_path | ERROR_MONITOR_RESULTS_PATH | storage/app/error-monitor | Where collected material may be kept. |
| retention_days | ERROR_MONITOR_RETENTION_DAYS | 90 | How long aggregates are kept. |
| status_codes | ERROR_MONITOR_STATUS_CODES | 500 | Statuses worth storing. |
| analysis.context_before_seconds / context_after_seconds | ERROR_MONITOR_CONTEXT_*_SECONDS | 1800 | Widen the analyzed period for correlation. |
| analysis.lock_seconds | ERROR_MONITOR_LOCK_SECONDS | 900 | Lifetime of the run lock. |
| masking.* | ERROR_MONITOR_MASKING_* | enabled | Replacement tokens, masked keys, removed headers, query string removal, extra patterns, length bound. |
| fingerprint.application_paths / vendor_paths | ERROR_MONITOR_APPLICATION_PATHS / ERROR_MONITOR_VENDOR_PATHS | app/,routes/,modules/,packages/ / vendor/,node_modules/ | Path fragments deciding which stack frames are yours. A vendor fragment always wins. |
| fingerprint.* | ERROR_MONITOR_FINGERPRINT_* | all included | Stack frame limit, and whether the line number, HTTP method and route take part in the identity. |
| github.* | ERROR_MONITOR_GITHUB_* | disabled | Reserved for the future issue integration. Never read by any HTTP call today. |
Keep defaults in the config file - the code never hardcodes them.
The daily command reads every configured source in one go:
php artisan error-monitor:run
php artisan error-monitor:run --date=2026-08-03
php artisan error-monitor:run --source=laravel --dry-run --json
php artisan error-monitor:run --skip-github
With no options it analyses yesterday, which is the day a morning run is
about. It resolves the period, reads Laravel then Apache access then Apache
error, correlates them, stores idempotently, hands the failures to an issue
publisher if one is installed, and finally drops aggregates past
retention_days.
Exit codes of error-monitor:run:
| Code | Meaning |
| --- | --- |
| 0 | Every source finished |
| 1 | The run failed outright, or every source failed |
| 2 | Misconfiguration: disabled package, unusable date, contradictory options |
| 3 | Another run holds the lock for the same period |
| 4 | Collectors ran but matched no log file |
| 5 | Some sources finished and others did not |
Sources fail independently: an unreadable Apache directory says nothing about
the Laravel log beside it, so one failing source does not discard the others'
results, and 5 is how a partial run reports itself. Retention pruning is
skipped entirely while any source is failing.
--dry-run writes nothing at all — no database, no publisher, no pruning — but
still reports what a real run would have stored. --skip-github only suppresses
the IssuePublisher call; this package contains no issue-tracker code.
// routes/console.php, or the schedule() method of your console kernel
use Illuminate\Support\Facades\Schedule;
Schedule::command('error-monitor:run')
->dailyAt('05:00')
->onOneServer()
->withoutOverlapping();
onOneServer() needs a shared cache store. Even without it the command takes
its own cache lock per period, and the database unique constraint remains the
real safety net.
error-monitor:analyze stays available for analysing an arbitrary period:
php artisan error-monitor:analyze
php artisan error-monitor:analyze --date=yesterday
php artisan error-monitor:analyze --from="2026-08-03 00:00:00" --to="2026-08-03 12:00:00"
php artisan error-monitor:analyze --source=laravel --dry-run --json
php artisan error-monitor:analyze --force
php artisan error-monitor:status
php artisan error-monitor:status --json
Exit codes of error-monitor:analyze:
| Code | Meaning |
| --- | --- |
| 0 | Analysis completed |
| 1 | Analysis failed |
| 2 | Misconfiguration: disabled package, unusable date, contradictory options |
| 3 | Another run holds the lock for the same period |
| 4 | Collectors ran but matched no log file |
Concurrent runs of the same period are prevented with a cache lock; the database
unique constraint remains the real safety net. error-monitor:status prints no
secrets - the GitHub token is only reported as configured or not.
Masking runs before anything is normalized, fingerprinted or persisted, and the original values are never returned or stored.
| Masked | Replacement |
| --- | --- |
| IPv4 / IPv6 | {ip} |
| E-mail addresses | {email} |
| Phone numbers (separated, +-prefixed, or a bare 10-11 digits behind a leading zero) | {phone} |
| UUIDs | {uuid} |
| Bearer tokens, JWTs, Authorization headers, CSRF tokens | {token} |
| Cookie / Set-Cookie headers, session identifiers | {session} |
| Passwords, API keys, client secrets, refresh tokens, provider key formats | {secret} |
| Query strings | removed |
Arrays are masked recursively, and any key listed in masking.mask_keys (or in
masking.remove_headers) has its whole value replaced, whatever it contains.
A key listed in masking.phone_keys is replaced with {phone} instead - free
text cannot tell an unseparated number from an amount, so the key settles it.
A number is only read as a phone number when it looks like one: it carries
separators, starts with +, sits behind a TEL: / phone= style label, or is
ten to eleven digits behind a leading zero. Bare integers are left alone, which
is what keeps amounts, quantities, line numbers, ids and path segments intact -
masking runs first, so anything it removes cannot be recovered later.
Two limits are worth knowing: the masker is pattern based, so unknown secret
formats need an entry in masking.patterns, and values longer than
masking.max_length are truncated before masking. If a rule cannot run at all,
the value is redacted instead of passed through.
Normalization replaces values that differ between two occurrences of the same failure - ids, timestamps, query strings, temporary paths, framework generated files, digests and long random values - and deliberately keeps the values that identify a failure: HTTP statuses, PHP error constants, SQLSTATE and driver error codes, line numbers, version numbers, amounts and quantities.
The access log sees what the application log cannot: a 502 or a 503 never
reaches PHP and therefore leaves no Laravel entry at all. Common and Combined
Log Format are read out of the box, rotated .gz generations are streamed
without ever being expanded onto disk, and a custom LogFormat is supported by
adding a regex with named groups to apache_access_patterns — name them time,
request and status, optionally client, bytes, referer, agent,
request_id and request_time.
The status here is reported by the server, never inferred, so events carry
metadata.status_source = access_log and status_estimated = false.
Query strings are cut from the path in the parser itself rather than left to the masker, because a token in a URL is routine in an access log. The client address goes through the normal masking, so no raw IP is stored.
Each 5xx is matched against the Laravel exceptions of the same moment, strongest signal first. The result is recorded, because a match is a judgement rather than a fact:
| correlation_method | Matched on | correlation_confidence |
| --- | --- | --- |
| request_id | A request id present on both sides | 1.0 |
| time_method_path | Same moment, HTTP method and normalized path | 0.8 |
| time_path | Same moment and normalized path | 0.6 |
| time | Proximity in time alone | 0.3 |
| none | Nothing matched | 0.0 |
Paths are compared after normalization, so /orders/12 and /orders/99 are the
same route. When several candidates are equally plausible the nearest in time is
chosen and the confidence is divided by the number of candidates, with
correlation_candidates recording how many there were.
A 5xx without a Laravel counterpart is never dropped. It is stored as its own
event with correlation_method: none — those are precisely the failures that
never reached the application.
Note that status_codes still decides what is persisted. It defaults to 500,
so set ERROR_MONITOR_STATUS_CODES=500,502,503,504 to keep gateway errors.
The error log holds the failures that never reached PHP at all: a process killed
for exhausting memory, a request that outlived its timeout, a FastCGI transport
that gave up, a permission the deploy got wrong. A PHP stack trace spanning
several lines becomes one event, and rotated .gz generations are streamed like
the access log.
Each entry is sorted into the kind of failure it describes, recorded in
metadata.error_category:
| Category | Recognised from |
| --- | --- |
| memory_exhausted | Allowed memory size … exhausted, out of memory |
| timeout | Maximum execution time … exceeded, read data timeout, AH01075 |
| php_fatal | PHP Fatal error, Uncaught …, PHP Startup, parse errors |
| permission | Permission denied, client denied by server configuration, AH01797 |
| fastcgi | FastCGI sent in stderr, proxy_fcgi, Premature end of script headers, AH01071 |
| configuration | .htaccess, Invalid command, AH00124 |
| missing_file | File does not exist, script not found |
| server_internal | child pid … exit signal, Segmentation fault, AH00052 |
| unknown | nothing matched — category_estimated is then true |
Rules run from specific to general, because the specific ones are the actionable
ones: an exhausted memory limit is also a PHP fatal error, and an AH01071
quoting a PHP fatal is a bug in the application rather than in the transport
that reported it.
An error log states no HTTP status, so it is derived from the category and every
event says so through status_source = error_category and
status_estimated = true. missing_file maps to 404 and permission to 403,
which is what keeps a scanner sweep out of the stored server errors: they are
parsed and then simply not stored under the default status_codes.
Entries can be annotated with a matching Laravel exception through the same
ApacheLaravelCorrelationService. An error log entry usually carries no request
path, so proximity in time is often all there is — which the recorded confidence
states rather than hides.
SHA-256 over environment, source, exception class, normalized message, the first
application file and line, the leading application stack frames, the HTTP method
and the normalized route. Vendor frames are used only when a trace has no
application frame. FingerprintGenerator::material() returns the same input for
inspection, and config('error-monitor.fingerprint') decides which parts count.
| Table | Purpose |
| --- | --- |
| error_monitor_events | Daily aggregate per failure. Unique on (environment, source, fingerprint, detected_date). Its payload_hash names the payload processed last and is kept for reference only; it is not what duplicate protection reads. |
| error_monitor_event_occurrences | One row per distinct payload merged into a daily aggregate, unique on (error_monitor_event_id, payload_hash). This is what makes re-analyzing a log a no-op: a day holding several distinct entries for one fingerprint remembers all of them, not just the newest. |
| error_monitor_issues | Failure to external issue correspondence, unique on (environment, fingerprint, repository). provider and external_id hold whichever tracker and identifier were used; the original issue_number / issue_state columns are still written when the identifier is numeric. |
The package can hand each stored failure to an issue tracker, and contains no
tracker code whatsoever. error-monitor:run builds an ErrorReportData — plain
text, no Markdown, no labels, no links — and asks a single method:
use Apkk\LaravelErrorMonitor\Contracts\IssuePublisher;
use Apkk\LaravelErrorMonitor\DTO\ErrorReportData;
use Apkk\LaravelErrorMonitor\DTO\IssuePublicationResultData;
final class MyTrackerPublisher implements IssuePublisher
{
public function enabled(): bool { return true; }
public function provider(): string { return 'my-tracker'; }
public function target(): string { return 'acme/shop'; }
public function publish(ErrorReportData $report): IssuePublicationResultData
{
return new IssuePublicationResultData(
externalId: '1234',
state: 'open',
action: IssuePublicationResultData::ACTION_CREATED,
url: 'https://tracker.example/1234',
);
}
}
// In the adapter package's service provider:
$this->app->singleton(IssuePublisher::class, MyTrackerPublisher::class);
action is one of created, commented, reopened, skipped or failed.
skipped is an outcome, not a warning — "already reported today" is the most
common thing a daily run has to say.
Identifiers are strings because not every tracker counts: GitHub hands out
1234, Jira hands out OPS-42.
Idempotency is shared. The core will not offer the same report twice — it records what it published, and a report is "the same" only while the day, the occurrence count and the last occurrence are unchanged. That keeps a repeated run from becoming a repeated API call, but it is a first line of defence rather than the whole of it: only the adapter can see what the tracker already holds, including anything a previous run created before losing its answer.
A publisher that fails should return IssuePublicationResultData::failure()
rather than throw. The run continues, nothing is recorded as delivered, and the
next run tries again. Adapters must never put a credential, an Authorization
header or a raw response body into a result, an exception or a log line.
With no publisher bound, nothing happens and nothing is reported as wrong.
Every step sits behind a contract, so rebinding one is enough to replace it. Log drivers are registered through container tags - the bundled Laravel driver registers itself the same way, and additional formats are purely additive:
use Apkk\LaravelErrorMonitor\ErrorMonitorServiceProvider;
$this->app->tag([ApacheAccessLogCollector::class], ErrorMonitorServiceProvider::COLLECTOR_TAG);
$this->app->tag([ApacheAccessLogParser::class], ErrorMonitorServiceProvider::PARSER_TAG);
A collector tags every file it finds with its own source key, and the analyzer
hands each file to the first parser whose supports() claims it, so parsers for
different formats never collide.
Shared hosting, an archive bucket, another machine entirely — anywhere the core
has no business knowing about — is served by the ServerLogSource contract:
use Apkk\LaravelErrorMonitor\Contracts\ServerLogSource;
use Apkk\LaravelErrorMonitor\DTO\CollectedLogFileData;
use Apkk\LaravelErrorMonitor\ErrorMonitorServiceProvider;
final class MyHostingLogSource implements ServerLogSource
{
public function id(): string
{
return 'my-hosting';
}
public function collect(?AnalysisWindowData $window = null): iterable
{
yield new CollectedLogFileData(
source: 'apache_access', // which parser claims it
path: '/tmp/fetched/access.log', // readable local path
targetDate: new DateTimeImmutable('2026-08-03'),
fileHash: CollectedLogFileData::hashOf('/tmp/fetched/access.log'),
compressed: false,
metadata: ['domain' => 'shop.example', 'server_identifier' => 'web01'],
);
}
}
// In the adapter package's service provider:
$this->app->tag([MyHostingLogSource::class], ErrorMonitorServiceProvider::SERVER_LOG_SOURCE_TAG);
Once the file is handed over it is indistinguishable from one found in
storage/logs: same parsers, same masking, same aggregation.
Where the line falls. The adapter owns everything about reaching the logs —
transport, credentials, retries, rate limits — and everything about deciding
which paths may be read at all: path traversal, symlinks and allow-listed
directories are judgements only the adapter can make, because only it knows what
"allowed" means in its environment. The core checks that the file it was handed
exists and is readable, deduplicates by source + target_date + file_hash, and
reads it.
A few things worth knowing:
.gz through gzopen, so
an adapter that expands a file first is doing work the core would have done
for free. Report it with compressed: true and hand over the .gz.ReadableLogStream
contract can follow if a source ever genuinely cannot produce a file.file_hash is an identity claim, not an integrity check. The core does not
recompute it — that would mean reading every byte of every log twice — it uses
it to recognise a file it has already seen.metadata is free-form. domain and server_identifier are conventions,
not requirements; nothing in the core reads them.id().Implemented:
single and daily channels, and a streaming parser for the Monolog default format including multi-line stack traces and the JSON context;LogFormat patterns, plus correlation with Laravel exceptions and a recorded confidence;error_monitor_events and the issue link repository;error-monitor:analyze and error-monitor:status with the options and exit codes above.On a stock installation error-monitor:analyze reads storage/logs, keeps the
entries whose HTTP status matches status_codes, and aggregates them per day.
Laravel logs client errors at ERROR level too, so the status is derived from
the log context first, then from the exception class, and only assumed to be
500 as a last resort. Every event records which of the three applied in
metadata.status_source, alongside metadata.status_estimated, so an assumed
status is never mistaken for a reported one.
GitHub API calls and issue creation, duplicate issue handling, AI agent API
calls, and hosting-specific log retrieval live in their own packages, never in
this one. In particular no XServer path convention, server id or API call
belongs in the core — the ServerLogSource contract above is the whole of
what the core offers such a package, and it is deliberately small enough that an
S3 or an SSH adapter fits the same shape.
.github/workflows/codex-issue-agent.yml runs the official Codex GitHub Action
to turn an issue into a plan, and an approved plan into a draft pull request. It
runs in two stages and never skips the first.
issue labelled ai-fix
→ investigate, post a plan (no file may change)
→ a person reads it, adds plan-approved
→ implement on ai/issue-{number}, run composer check, open a draft PR
| Label | Meaning |
| --- | --- |
| ai-fix | Ask the agent to look at this issue |
| plan-approved | A person read the plan and wants it implemented |
| ai-running | An implementation is in progress |
| ai-done / ai-failed | How the last run ended |
| plan-review-required | The subject is never implemented automatically |
An issue body is untrusted input — anyone can open one on a public repository — so the gates are on the actor and the subject, not on the text:
OWNER, MEMBER and COLLABORATOR issues are acted on at all.ai-fix or plan-approved may start agent work. Status labels
added by the workflow itself are ignored, so they cannot cause another API
run.plan-approved. A plan alone is not
enough, and a label alone is not enough.plan-approved is a judgement about a plan, not a waiver on the subject.ai/issue-{number}. The push step verifies the branch
name and refuses anything else, so main is never written to.composer check passes, and it is opened as
a draft.The decision logic lives in .github/scripts/IssueAgentDecision.php and is unit
tested, because a YAML if: expression cannot be.
The workflow needs one repository secret an administrator has to provide:
OPENAI_API_KEY repository Actions secret.The OpenAI Project for that key must allow gpt-5.6-terra. Both workflow jobs
are fixed to that model with medium reasoning effort.
The key is separate from a ChatGPT subscription. No Claude GitHub App or
Anthropic key is used, and the workflow does nothing without OPENAI_API_KEY.
composer update # no composer.lock is committed; a library resolves fresh
composer test # PHPUnit through Orchestra Testbench
composer format # Pint
composer format:test # Pint, check only
composer analyse # PHPStan
composer check # all three
CI runs the same gates on PHP 8.2-8.5 against Laravel 10/11/12/13, plus the migrations and both Artisan commands inside a Testbench application, and audits the current dependency set in a separate job.
A separate Integration job installs all three packages together in
tests/IntegrationApp/ — an application that exists only to prove they agree
about the contracts between them — and runs an end-to-end suite from an XServer
gzip log through to a faked GitHub issue. The core's own composer.json never
depends on the adapters; the integration app reaches them through Composer path
repositories, so the three repositories are checked out as siblings in CI:
workspace/
├── laravel-error-monitor/
├── laravel-error-monitor-xserver/
└── laravel-error-monitor-github/
cd tests/IntegrationApp && composer update && composer test
Fixtures must use only synthetic and reserved documentation values. Never commit production logs, real IP addresses, email addresses, tokens, cookies, or sessions.
MIT. See LICENSE.