Date: 2026-03-21 Auditor: Elly (Claude Code, TAB-2) Repo: nash87/parkhub-php Branch: main (HEAD: 6386981) Version: 1.9.x (Laravel 12, PHP 8.2+)
- Executive Summary
- Feature Inventory
- Security Audit
- Code Review
- Tech Debt Analysis
- Testing Strategy
- CI/CD Review
- Documentation
- Priority Issue List
ParkHub PHP is a mature, well-structured Laravel 12 self-hosted parking management system. Overall quality is above average for an open-source project. Security posture is solid, CI/CD is functional, and test coverage is broad. The main gaps are:
| Area | Status | Rating |
|---|---|---|
| Security | Good. Few medium issues. | ★★★★☆ |
| Code Quality | Good. Some structural debt. | ★★★★☆ |
| Testing | Broad but shallow unit coverage. No phpstan. | ★★★☆☆ |
| CI/CD | Functional. Missing static analysis + security scan. | ★★★☆☆ |
| Documentation | Excellent. Most comprehensive in class. | ★★★★★ |
| Feature | Implementation | Tests |
|---|---|---|
| Auth (login/register/password reset) | AuthController, Sanctum tokens, rate-limited | ✅ Full |
| Parking Lots (CRUD) | LotController, per-lot pricing | ✅ Full |
| Zones (per-lot) | ZoneController | ✅ |
| Slots (per-lot, with types/features) | SlotController, features JSON column | ✅ Full |
| Standard Bookings | BookingController | ✅ Full |
| Quick Book (auto-assign slot) | BookingController::quickBook | ✅ |
| Guest Bookings | BookingController::guestBooking | ✅ |
| Booking Swap | BookingController::swap | ✅ |
| Recurring Bookings | RecurringBookingController, expansion job | ✅ Full |
| Absences | AbsenceController | ✅ Full |
| iCal Import (absences) | AbsenceController::importIcal | ✅ |
| iCal Export (calendar) | UserController::calendarExport | ✅ |
| Vehicles (CRUD) | VehicleController | ✅ |
| Vehicle Photo Upload | VehicleController::uploadPhoto | ✅ |
| Waitlist | WaitlistController, notification on slot open | ✅ |
| Credits System | AdminCreditController, monthly quota | ✅ Full |
| Team View | TeamController | ✅ |
| User Favorites | UserController::favorites | ✅ |
| Notifications (push + in-app) | PushNotificationService, VAPID | ✅ |
| Webhooks | MiscController::webhooks | ✅ |
| Announcements | AdminAnnouncementController, expiry | ✅ |
| Admin User Management | AdminController | ✅ Full |
| Bulk User Import (CSV/JSON) | AdminController::importUsers | ✅ |
| Admin Bookings | AdminController::bookings | ✅ |
| Admin Reports & Heatmap | AdminReportController | ✅ |
| CSV Export (bookings/users) | AdminReportController | ✅ |
| Admin Settings | AdminSettingsController | ✅ |
| Branding / Logo Upload | AdminSettingsController | ✅ |
| GDPR Export & Delete | GdprTest, UserController | ✅ |
| Audit Log | AuditLog model, logged throughout | ✅ |
| Email Notifications | BookingConfirmation, Reminder, Welcome, WaitlistSlotAvailable, PasswordReset | ✅ |
| PWA | parkhub-web (React 19 + Vite) | ✅ |
| Public Occupancy Display | PublicController | ✅ |
| Prometheus Metrics | MetricsController | ✅ |
| Per-Lot Pricing | ParkingLot: hourly_rate, daily_max, monthly_pass, currency | ✅ |
| Slot Types/Features | parking_slots.features JSON | ✅ |
| PDF Invoice (HTML print) | BookingInvoiceController | ✅ |
| QR Code (booking) | MiscController::qrCode | ✅ |
| Recommendation Engine | RecommendationController (score-based) | ✅ |
| Translation System | TranslationController, proposals, votes | ✅ |
| Setup Wizard | SetupController | ✅ |
| Demo Mode | Docker entrypoint seeding | ✅ |
| Pulse Monitoring | PulseController | ✅ |
| Multi-locale (10+ languages) | parkhub-web i18n | ✅ |
| Dark Mode | parkhub-web | ✅ |
| PostgreSQL Support | Added in v1.9.x | ✅ |
| Feature | Status | Notes |
|---|---|---|
| German License Plate OCR | Partial | Photo upload exists; OCR backend not implemented |
| OTA Updates | Not in this repo | Separate ota.securanido.com |
Sanctum Token Auth — Well configured:
- Tokens expire after 7 days (
expiration: 10080inconfig/sanctum.php) throttle:auth(5/min) on login/register — good brute-force protectionthrottle:password-reset(3/15min) — correct- Password reset uses hashed tokens (not plaintext), 60-minute expiry
- Generic response on forgot-password prevents user enumeration ✅
- Password requires uppercase + lowercase + digit + 8+ chars ✅
- All sessions invalidated on password change/reset ✅
Admin Authorization — Two-layer protection:
- Route-level:
Route::middleware(['admin'])viaRequireAdminmiddleware - Controller-level:
$this->requireAdmin($request)redundantly in AdminController methods - This is fine (defense in depth), but the controller-level checks are redundant given the middleware
Issue — Double requireAdmin in AdminController: The admin middleware is applied to the entire admin route group, so the private function requireAdmin() inside AdminController is dead code. Not a vulnerability, but confusing.
All endpoints validate via $request->validate() — Laravel's built-in validator (uses parameterized queries automatically). No raw input in SQL.
Raw SQL usage review:
LotController: selectRaw('lot_id, COUNT(*) as total') — safe, no user input
AdminReportController: selectRaw('CAST(strftime...)') — safe, hardcoded
PublicController: DB::raw('COUNT(*) as occupied') — safe
No SQL injection vectors found. ✅
CSV Injection Protection:
csvSafe() in AdminReportController prefixes values starting with =, +, -, @, tab, CR with a single quote — standard protection. ✅
SecurityHeaders middleware applied globally. Headers set:
| Header | Value | Assessment |
|---|---|---|
| X-Content-Type-Options | nosniff | ✅ |
| X-Frame-Options | DENY | ✅ |
| X-XSS-Protection | 1; mode=block | ✅ (legacy, fine) |
| Referrer-Policy | strict-origin-when-cross-origin | ✅ |
| Permissions-Policy | restrictive | ✅ |
| HSTS | opt-in via APP_HSTS | ✅ |
| CSP | script-src 'self', style-src 'self' 'unsafe-inline' |
Issue — CSP style-src 'unsafe-inline': Allows inline styles. While the SPA uses bundled CSS, this weakens CSP. Could be tightened with a nonce or hash approach, or by eliminating inline styles.
Explicit allowed origins (not wildcard). Explicit allowed methods and headers. Allows GitHub Pages pattern for demo. ✅
Minor: allowed_origins includes APP_URL which comes from env(). In testing with in-memory SQLite, this would be null — array_filter handles it. ✅
Vehicle photos (VehicleController::uploadPhoto):
- Validates
mimes:jpeg,png,gif,webp|max:5120✅ - Accepts base64 alternative — uses
base64_decodewith strict mode - Stores to
localdisk (not public) — served viaservePhotoaction, ownership-checked ✅ - File extension forced to
.jpgregardless of upload — good
Branding logo (AdminSettingsController::uploadBrandingLogo):
- Validates
image|max:2048✅ - Stores to
publicdisk (served directly) ✅
GET /api/metrics — No auth by default. When METRICS_TOKEN is set in config, bearer token is required. Leaks: total users count, booking counts, occupancy. Medium risk in untrusted networks. Recommend requiring token by default.
GET /api/public/occupancy and GET /api/public/display — Expose lot names, occupancy data. Intentional (for lobby displays). No PII. ✅
Regex parser, max 1MB input, no external library. The regex BEGIN:VEVENT(.*?)END:VEVENT with /s flag handles multiline. No XXE (not XML). Summaries truncated to 255 chars. ✅
Minor: Very large iCal files could be slow — no processing time limit beyond the 1MB size cap.
Stores Hash::make($token) in password_reset_tokens — not plaintext. 60-minute expiry. Deleted after use. ✅
After failed login, only rate limiting (5 attempts/min) prevents brute force. No per-account lockout (is_active = false after N failures). With rate limiting this is acceptable, but an optional lockout policy would strengthen this.
Clean Laravel 12 structure: Controllers → Resources → Models. Middleware layering is correct. Jobs for async operations (email, webhooks, stats). Services for push notifications.
BookingController::store checks slot availability with a where query, then creates — no distributed lock or database-level unique constraint prevents double-booking in concurrent requests.
// BookingController.php — vulnerability window between check and insert
$conflict = Booking::where('slot_id', $slotId)...->exists();
if ($conflict) return 409;
// ← another request could book the same slot here
$booking = Booking::create([...]);Fix: Wrap in DB::transaction() with a SELECT FOR UPDATE or add a unique index on (slot_id, start_time, end_time) to make the duplicate fail at DB level.
ParkingLot::available_slots is a persisted column but recalculated on every LotController::index call (correctly). However, MetricsController reads it directly from the DB column, which may be stale:
// MetricsController — reads stale available_slots
$occupied = $total - $lot->available_slots;This is a consistency issue: the column isn't auto-updated on booking creation. The LotController::index recalculates correctly, but other paths don't.
The admin middleware is applied in routes, but AdminController also has a private requireAdmin() method called in every action. This is dead code — the middleware already rejects non-admins before the controller runs. Not a bug, but dead code that misleads reviewers.
If end_time is null, the booking is created without an end time. The validation says nullable. Some downstream code (BookingInvoiceController) does $end = $booking->end_time ? ... : $start + 3600 — so there's a fallback, but an open-ended booking is conceptually odd for a parking system.
RecommendationController — N+1 Hidden
foreach ($lots as $lot) {
foreach ($lot->slots as $slot) { // slots eager-loaded ✅ but...
$features = $slot->features ?? []; // JSON column cast — OKLots are eager-loaded with slots. OK.
TranslationController, TranslationProposal, TranslationVote models exist. Community translation voting is an interesting addition but adds significant complexity with limited test coverage.
// PushNotificationService.php
'subject' => 'mailto:admin@parkhub.test',VAPID subject should use the configured admin email, not a hardcoded test address. This could cause push notification delivery issues in production.
| # | Issue | Impact | Effort |
|---|---|---|---|
| TD-1 | No phpstan/larastan — no static analysis | Bugs slip to runtime | Low |
| TD-2 | Booking race condition (no transaction/lock) | Double-booking possible | Medium |
| TD-3 | MetricsController reads stale available_slots |
Inaccurate Prometheus metrics | Low |
| TD-4 | No composer audit in CI | Known CVEs undetected | Low |
| TD-5 | VAPID subject hardcoded to test email | Push failures in production | Low |
| # | Issue | Impact | Effort |
|---|---|---|---|
| TD-6 | AdminController::requireAdmin() private method is dead code |
Confusion in code review | Low |
| TD-7 | Prometheus metrics endpoint unprotected by default | Data leakage | Low |
| TD-8 | CSP style-src 'unsafe-inline' |
Weak CSP | Medium |
| TD-9 | Only 1 unit test (ExampleTest) — no unit coverage for services/models | No isolated test signal | High |
| TD-10 | No soft deletes on Booking/User | Can't restore accidentally deleted data | Medium |
| TD-11 | BookingController too large (~800 lines) |
Maintenance burden | High |
| TD-12 | DB-specific raw SQL (SQLite strftime vs MySQL DAYOFWEEK) | Fragile dual-DB support | Medium |
| TD-13 | No account lockout (only rate limiting) | Brute force under distributed IPs | Medium |
| # | Issue | Impact | Effort |
|---|---|---|---|
| TD-14 | No sanctum:prune-expired scheduled command |
Token table grows indefinitely | Low |
| TD-15 | Booking end_time nullable — semantic ambiguity |
Edge cases | Low |
| TD-16 | TranslationController untested |
New feature without coverage | Medium |
| TD-17 | No OpenAPI/Swagger spec | API consumers need manual reading | High |
| TD-18 | health endpoint returns hardcoded version: 1.3.0 |
Stale version string | Low |
| TD-19 | Import users: N queries in loop (1 INSERT + 1 EXISTS per user) | Slow at scale | Medium |
| Metric | Value |
|---|---|
| Total tests | 484 |
| Feature tests | 48 files |
| Unit tests | 1 file (ExampleTest only) |
| Test runner | PHPUnit 11 |
| Coverage reporting | None configured |
| Static analysis | None (no phpstan/larastan) |
| Database | SQLite in-memory |
| E2E | Maestro YAML (5 flows) |
Strengths:
- Broad feature coverage: all major controllers have dedicated test files
- Edge case files exist:
AuthEdgeCaseTest,BookingEdgeCaseExtendedTest, etc. - Security headers tested explicitly (
SecurityHeadersTest) - GDPR flows tested
- Job queue tested (
JobQueueTest— 510 lines) - Rate limiting scenarios covered
- Pagination tested
Weaknesses:
- Zero unit tests for services/models:
PushNotificationService,AuditLog, models with business logic — none tested in isolation - No coverage report: Can't track which lines are actually covered
- Only SQLite: PostgreSQL-specific SQL (DAYOFWEEK, HOUR functions) never tested in CI
- Missing scenarios:
- Concurrent booking (race condition not testable without real DB)
- VAPID key generation command
- RecurringBookingExpansion job edge cases
- Translation voting logic
- Credit edge cases across month boundaries
- No Pest: PHPUnit 11 works, but Pest would enable data providers and cleaner test organization
- No mutation testing: Infection PHP would identify untested branches
- Add larastan (Level 5+) to CI
- Enable coverage (
--coverage-text) in CI withxdebugorpcov - Add PostgreSQL test job (separate GH Actions job)
- Expand
tests/Unit/with at minimum:UserTest,BookingTest,PushNotificationServiceTest - Add
infection/infectionfor mutation score tracking
| Job | Status | Notes |
|---|---|---|
| PHP Tests | ✅ Works | SQLite only |
| PHP Lint (Pint) | ✅ Works | Style enforced |
| Frontend Build & Test | ✅ Works | Vitest |
Gate job ci |
✅ Correct | Branch protection target |
Missing from CI:
composer audit— no CVE scanningphpstan/larastan— no static analysis- PHP 8.4 vs 8.2 matrix — CI runs 8.4, minimum is 8.2 (untested combination)
- PostgreSQL test run
- Coverage report
- SAST (e.g. Psalm, Semgrep)
| Feature | Status | Notes |
|---|---|---|
| GHCR push on tag | ✅ | Semver tags |
| Build cache via registry | ✅ | Efficient |
| Multi-platform | linux/amd64 only, no arm64 | |
| Render deploy | ✅ | Auto-deploy on tag/main |
| Env var injection via Render API | Demo credentials in workflow (acceptable for demo) |
# Add to ci.yml jobs:
static-analysis:
name: Static Analysis (Larastan)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with: { php-version: '8.4', tools: composer:v2 }
- run: composer install --prefer-dist --no-interaction --no-progress
- run: ./vendor/bin/phpstan analyse --memory-limit=512M
security:
name: Security Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with: { php-version: '8.4', tools: composer:v2 }
- run: composer install --prefer-dist --no-interaction --no-progress
- run: composer audit| Document | Status | Quality |
|---|---|---|
| README.md | ✅ Redesigned | Professional, badges, screenshots |
| ARCHITECTURE.md | ✅ Present | Detailed tech overview |
| CHANGELOG.md | ✅ Present | Well-maintained |
| docs/API.md | ✅ Present | Comprehensive REST reference |
| docs/CONFIGURATION.md | ✅ | All env vars documented |
| docs/DOCKER.md | ✅ | Detailed Docker Compose guide |
| docs/INSTALLATION.md | ✅ | Multiple install paths |
| docs/GDPR.md | ✅ | GDPR compliance guide |
| docs/SECURITY.md | ✅ | Security policy |
| docs/PAAS.md | ✅ | Render/Koyeb guide |
| docs/SHARED-HOSTING.md | ✅ | Shared hosting support |
| SECURITY-AUDIT.md | ✅ | Self-audit (older) |
| COMPLIANCE-REPORT.md | ✅ | Compliance tracking |
| OpenAPI/Swagger spec | ❌ Missing | Needed for SDK generation |
| CONTRIBUTING.md | ✅ Present |
Gap: No OpenAPI 3.x spec (openapi.yaml). The API.md is thorough but machine-unreadable. Adding an OpenAPI spec would enable automatic SDK generation and interactive docs.
| ID | Title | Label |
|---|---|---|
| SEC-1 | Booking race condition: concurrent requests can double-book same slot | bug, security, P0 |
| ID | Title | Label |
|---|---|---|
| CI-1 | Add larastan to CI pipeline | ci, dx, P1 |
| CI-2 | Add composer audit security scan to CI |
ci, security, P1 |
| BUG-1 | VAPID subject hardcoded to admin@parkhub.test — push fails in production |
bug, P1 |
| BUG-2 | MetricsController reads stale available_slots column |
bug, P1 |
| ID | Title | Label |
|---|---|---|
| TD-1 | Remove redundant requireAdmin() in AdminController |
tech-debt, P2 |
| TD-2 | Prometheus metrics should require token by default | security, P2 |
| TD-3 | Add Sanctum token pruning to scheduled commands | tech-debt, P2 |
| TD-4 | Expand unit tests: UserModel, BookingModel, PushNotificationService | testing, P2 |
| TD-5 | Add PostgreSQL CI job | ci, testing, P2 |
| TD-6 | Health endpoint returns hardcoded version 1.3.0 |
bug, P2 |
| TD-7 | Tighten CSP: remove style-src 'unsafe-inline' |
security, P2 |
| TD-8 | Add soft deletes to Booking and User models | tech-debt, P2 |
| ID | Title | Label |
|---|---|---|
| ENH-1 | Generate OpenAPI 3.x spec | documentation, enhancement, P3 |
| ENH-2 | Add arm64 to Docker multi-platform build | ci, enhancement, P3 |
| ENH-3 | Add optional account lockout after N failed logins | security, enhancement, P3 |
| ENH-4 | Split BookingController into smaller focused classes | tech-debt, refactor, P3 |
| ENH-5 | Add Pest migration / upgrade from PHPUnit | testing, enhancement, P3 |
| ENH-6 | Add coverage reporting (pcov) to CI | testing, ci, P3 |
| ENH-7 | Optimize bulk user import: batch INSERT instead of loop | performance, P3 |
| ENH-8 | Implement German license plate OCR backend | feature, P3 |
| ENH-9 | Add booking:end_time required validation or default duration |
enhancement, P3 |
| ENH-10 | Add mutation testing with Infection PHP | testing, enhancement, P3 |
// composer.json require:
"laravel/framework": "^12.54" // Latest — ✅
"laravel/sanctum": "^4.3" // Latest — ✅
"minishlink/web-push": "^10.0" // Latest stable — ✅
// require-dev:
"phpunit/phpunit": "^11.5.50" // Latest — ✅
"laravel/pint": "^1.24" // Latest — ✅
// Missing: nunomaduro/larastan, phpstan/phpstanNo composer audit results available (not in CI). Recommend running locally and in CI.
Report generated by Elly — automated audit via code analysis. All issues have been filed as GitHub Issues on nash87/parkhub-php.