- GET /partner-stores/nearby — public, residents browse active stores
ranked by distance via ST_Distance_Sphere; PublicPartnerStoreResource
excludes commission rate / owner / permit (only what a buyer needs).
- GET /partner-stores/{uuid} — public details, 404 if not active.
- GET /me/collections — paginated resident QR scan history with
optional from/to date filters.
- GET /me/upcoming-pickups — finds scheduled/in-progress trips whose
route includes the resident's assigned drop-off point. Returns
household_assigned: false when no household yet.
- GET /me/notifications — paginated database notifications inbox
with unread_only filter + unread_count in meta.
- POST /me/notifications/{id}/read, POST .../mark-all-read,
GET .../unread-count.
- config/cors.php — allow CUSTOMER_APP_URL and any EXTRA_CORS_ORIGINS
to call the API with credentials. Same-origin admin web is
unaffected.
202 feature tests passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
22 KiB
Verde Backend (Laravel 11)
See ../CLAUDE.md for project-wide context. This file covers backend-specific
conventions.
Stack
- Laravel 11.51, PHP 8.4
- MySQL 9.2 via DBngin (host 127.0.0.1, root user, no password, db
verde, test dbverde_testing) - Sanctum for token auth
- Spatie: laravel-permission, laravel-activitylog, laravel-model-states
- matanyadaev/laravel-eloquent-spatial for MySQL POINT/POLYGON
Run
php artisan serve --host=127.0.0.1 --port=8000
Health: GET http://127.0.0.1:8000/api/v1/health
Tests
php artisan test
Tests use the verde_testing database (configured in phpunit.xml) with
RefreshDatabase. SMS driver in tests is fake (an in-memory recorder).
DBngin MySQL CLI
The mysql binary is not on PATH. Use:
/Users/Shared/DBngin/mysql/9.2.0_arm/bin/mysql -u root -h 127.0.0.1
Conventions
API Response Envelope
Every API response uses:
{
"success": true|false,
"data": <payload or null>,
"message": "<human readable or null>",
"errors": <validation errors map or null>,
"meta": { ... }
}
Implemented in App\Http\Responses\ApiResponse. Controllers extend
App\Http\Controllers\Api\V1\ApiController and use $this->ok(),
$this->created(), $this->fail(), $this->notFound(), etc.
Routing
- All API routes under
/api/v1(prefix configured inbootstrap/app.php) - Controllers in
app/Http/Controllers/Api/V1/... - Route names follow
api.v1.<resource>.<action>
Auth
- Sanctum bearer tokens for mobile (token in
Authorization: Bearer <t>) - Auth endpoints under
/api/v1/auth/*— seeroutes/api.php - Role middleware alias
role:<role>[,<role>...](defined inApp\Http\Middleware\EnsureUserHasRole, registered inbootstrap/app.php) - Spatie roles seeded by
RoleSeeder(admin/resident/driver/helper/scanner/ store_partner). Theusers.rolecolumn is the discriminator; Spatie roles mirror it for permission checks.
OTP
- Codes stored hashed in
otp_codes(purpose, destination, attempts cap = 5, expires_at, consumed_at). SeeApp\Models\OtpCode. App\Services\Otp\OtpServiceissues + verifies codes with TTL/cooldown controlled byservices.otp.*config.- SMS goes through
App\Services\Sms\SmsServicecontract. Drivers:LogSmsService(default — writes to log),SemaphoreSmsService(prod),FakeSmsService(tests). Driver chosen bySMS_DRIVERenv. - In
local/testingenvironments, register/forgot/resend responses includedata.debug_codefor easy manual testing — strip inproduction.
Exceptions
Global handlers in bootstrap/app.php ensure ValidationException,
AuthenticationException, NotFoundHttpException, and HttpExceptionInterface
all return the standard envelope on api/* requests. Don't write per-controller
try/catch for these — let them bubble.
Models
- All models use SoftDeletes unless noted otherwise
- User model has role constants:
User::ROLE_ADMIN,User::ROLE_RESIDENT, etc. - Auto-generated UUID on User creation via the
booted()hook - Activitylog enabled on User; add
LogsActivityto other models that need audit
Migrations
- snake_case plural table names
- bigint id PK, separate
uuidfor public-facing identifiers - Spatial columns use
Point/Polygontypes (SRID 4326) - Add
SPATIAL INDEXon geometry columns
Testing
Use feature tests with RefreshDatabase. Aim for happy path + 2-3 error cases
per endpoint. Tests live in tests/Feature/Api/V1/. For routes that send SMS,
bind FakeSmsService via $this->app->instance(SmsService::class, ...) in
setUp().
Module Status
- Module 1: Foundation & Auth — complete
- register / login / logout / refresh / me
- forgot-password / reset-password
- OTP verify / resend (Semaphore + log + fake drivers)
- Role middleware + RoleSeeder + AdminUserSeeder
- Module 2: Geographic Data — complete
- PSGC tables: regions, provinces, cities_municipalities, barangays
- Spatial columns via Laravel 11 native
geometry()(subtype: polygon/point, SRID 4326), cast to matanyadaev Polygon/Point objects on the model - Cascading dropdown endpoints:
GET /geo/{regions,provinces,cities,barangays} - GPS resolve:
POST /geo/resolve {lat,lng}→ barangay (uses ST_Contains against barangay polygons; cached barangay id by 1e-6 lat/lng key) - Service area CRUD:
/service-areas(admin-only), barangay attach/detach - SamplePsgcSeeder (NCR + 5 cities + 7 barangays with rough box polygons)
php artisan psgc:import {file.json}to ingest a full dataset later
- Module 3: User Management — complete
- 5 profile tables: resident_profiles, driver_profiles, helper_profiles, scanner_profiles, store_partner_profiles (1:1 to users, soft deletes)
- Driver/helper/scanner/store_partner profiles carry verification_status (pending/approved/rejected) + verified_at/by + rejection_reason
User::profileRelation()/User::profile()dispatches to the right profile by role;User::profileModelForRole()for create-on-registerHasProfileVerificationtrait shared by all verifiable profiles- Auto-creates an empty profile row on
/auth/register(same DB transaction) - Admin CRUD:
GET /admin/users(filters: role, status, q, verification_status, paginated),GET/PATCH/DELETE /admin/users/{uuid},POST .../suspend|activate|approve-profile|reject-profile - Admins cannot suspend or delete other admins via these endpoints
- Self-service:
PATCH /me,GET /me/profile,PATCH /me/profile(role-aware validation; cannot self-set verification_status; resubmitting a rejected profile resets it to pending)
- Module 4: Household Registration — complete
households(uuid, head_user_id, barangay_id, coordinates POINT 4326, address_line, household_size, proof_of_residency_path, verification fields)household_memberspivot (household_id, user_id, relationship enum, full_name, date_of_birth, joined_at) — supports non-user members- Resident endpoints:
POST /households(one per head, auto-resolves barangay from GPS),GET /households/{uuid},PATCH /households/{uuid},POST .../proof(file upload),POST/DELETE .../members - Admin endpoints:
GET /admin/households(filters: status, barangay, q),GET .../{uuid},POST .../approve(requires proof on file, firesHouseholdVerifiedevent),POST .../reject(with reason) HouseholdVerifiedevent registered with placeholder listenerAllocateFreeQrCodesOnHouseholdVerified— Module 7 will replace the body with actual QR batch allocation- Approved households are immutable to residents (must contact admin)
- Resubmit-after-rejection auto-resets to
pending
- Module 5: Drop-off Points — complete
drop_off_points(uuid, name, code, barangay_id, coordinates POINT 4326 withSPATIAL INDEX, capacity_kg, operating_hours JSON, accepted_waste_types JSON, status, photo, contacts)drop_off_capacity_logs(fill_percent + recorded_by + notes)- Added
assigned_drop_off_point_idFK tohouseholds - Public endpoints:
GET /drop-off-points/nearby?lat&lng&radius_km(usesST_Distance_Sphere, returnsdistance_meters, sorts by distance, excludes inactive DOPs);GET /drop-off-points/{uuid} - Admin CRUD:
GET/POST/PATCH/DELETE /admin/drop-off-points,POST .../{uuid}/capacityto log a fill reading App\Services\DropOff\DropOffPointFinder::nearby()/nearest()— used by household auto-assign onPOST /households(setsassigned_drop_off_point_idto nearest active DOP within 25km)POST /households/{uuid}/reassign-drop-offre-runs the lookup- SampleDropOffPointsSeeder creates 5 DOPs around the sample barangays
- Module 6: Dumpsites — complete
dumpsites(uuid, name, code, city_municipality_id, coordinates POINT 4326 withSPATIAL INDEX, boundary_polygon POLYGON 4326, capacity_tons, operating_hours JSON, accepted_waste_types JSON, permit_number, contacts)dumpsite_releasesschema in place (trip_id is nullable bigint without FK; Module 10 will add the constraint whentripsexists)- Admin CRUD:
GET/POST/PATCH/DELETE /admin/dumpsites - Boundary input is
[{lat,lng}, ...](≥3 points); auto-closes the ring if the client doesn't repeat the first point Dumpsite::containsPoint(lat, lng)for geofence checks viaST_Contains— Module 10 firesarrived_at_dumpsitebased on this- SampleDumpsitesSeeder creates a sample Payatas-area dumpsite with a rectangular boundary so geofence tests + dev work
- Module 7: QR Code System — complete (CORE)
qr_code_batchesandqr_codestables;target_store_id/assigned_to_store_idare nullable bigints until Module 12 adds FKs- State machine via
spatie/laravel-model-states:unassigned → allocated → active → used, plusexpired/voidedside states. Classes underapp/States/QrCode/. - Serial format
PH-{area}-{YYMM}-{batch}-{code}-{checksum}viaQrSerialGenerator; 2-char SHA-256-derived alphanumeric checksum.isValid()rejects tampered serials. BatchGenerator::generate()— bulk-inserts in 1000-row chunks for big batches; per-day batch sequence; transactional.QrAllocator::allocateFreeToHousehold()allocates the configured quota (config('qr.free_allocation_per_household'), default 10) from a free batch — prefers a batch whose target_area covers the household's barangay, falls back to any active free batch.HouseholdVerifiedlistener now does real allocation. Don't register listeners explicitly inAppServiceProvider::bootif they follow theListener@handle(Event)convention — Laravel 11 auto-discovers them, and double-registering causes double-dispatch.QrBalanceLowevent fires fromQrAllocator::notifyBalanceIfLow()(Module 11 will hit this from the scan flow).- Admin endpoints:
GET/POST /admin/qr-batches,GET .../{batch},POST .../{batch}/mark-printed,GET .../{batch}/print.pdf,GET /admin/qr-codes/{serial}(lifecycle search),POST /admin/ qr-codes/{serial}/void(with reason). - Resident endpoints:
GET /me/qr-codes,GET /me/qr-codes/balance(counts by status + low_balance flag),POST /me/qr-codes/{serial}/ activate. - PDF:
barryvdh/laravel-dompdf+endroid/qr-code(v6 — usenew Builder(writer:, data:, ...)constructor; staticBuilder:: create()was removed) +picqer/php-barcode-generator. Blade template atresources/views/pdf/qr-batch.blade.phprenders 24 cards/A4 (4×6, QR + Code-128 + serial). config/qr.phpwithfree_allocation_per_householdandlow_balance_thresholdenv-overridable.
- Module 8: Routes — complete
routes(uuid, name, code, area_id, default_dumpsite_id, default_team_id nullable bigint until Module 9, estimated_duration_minutes, total_distance_km, status)route_stopspivot (route_id, drop_off_point_id, sequence, estimated_duration_at_stop_minutes;UNIQUE(route_id, sequence))- Admin CRUD:
GET/POST/PATCH/DELETE /admin/routes,POST .../{uuid}/clone(clones to inactive with-COPY-XXXXsuffix) - Stops are submitted as an ordered array; controller assigns sequence numbers from array index (drag-reorder is purely client-side)
App\Services\Route\RouteCalculatorrecomputestotal_distance_kmandestimated_duration_minutesafter every create/update/clone via MySQLST_Distance_Spherebetween consecutive stop coordinates + dumpsite, plus dwell time at stops, +20 min at dumpsite, divided byconfig('routes.avg_speed_kmh', 25.0)
- Module 9: Teams + Trucks — complete
trucks(with last_known_coordinates POINT 4326),collection_teams,team_members. Promotedroutes.default_team_idto a real FK.- Admin CRUD for trucks and teams;
TeamConflictDetectorflags double-assignment of driver/scanner/truck/active helper across teams;override_conflicts: truebypasses for emergency rotations.
- Module 10: Trips + Timeline — complete
trips,trip_stops,trip_timeline_events. Trip number autoTRIP-YYYYMMDD-NNN. Promoteddumpsite_releases.trip_idto FK.- Admin: schedule (clones route stops onto trip_stops), show, cancel.
- Driver:
start,arrive/depart/skipper stop,report-incident,arrive-dumpsite,release-load(creates DumpsiteRelease + tallies trip total_load_kg),complete. Each call writes a typed timeline event with GPS viaTripExecutor::log().
- Module 11: Scanning + Collection Logs — complete
collection_logs.ScanService::scan()validates: code state must beactive(used→duplicate, expired→expired, otherwise→invalid), GPS within 200m of DOP viaST_Distance_Sphere. On accept: transitions code toused, writes log, increments stop scan count, firesqr_scannedtimeline event, dispatchesQrBalanceLowif household active count drops below threshold.POST /scanner/scanandPOST /scanner/scan/bulk(offline sync; each scan processed independently).
- Module 12: Partner Stores — complete
partner_stores,store_inventories,store_purchases,store_sales. Promoted QR-code/batch FKs to partner_stores.StoreOperations::issueWholesale()generates a fresh batch targeted at a store, marks codesallocatedto that store, tops up inventory.StoreOperations::sellToHousehold()flips N codes from allocated→active reassigned to the household's id, computes commission at the store's rate, decrements inventory.
- Module 13: Reports + Analytics — complete (Reports/Analytics
sub-module of Module 13; Payments/Notifications/Live Tracking deferred)
daily_collection_stats,weekly_route_performance,monthly_store_salesaggregation tables.App\Services\Report\Aggregatorrebuilds each. Idempotent — deletes the slice and re-inserts.php artisan reports:aggregate --date=YYYY-MM-DDfor nightly job (default: yesterday).- Admin endpoints:
GET /admin/reports/{daily-collection, trip-performance,store-sales}chart-ready (series + totals),GET /admin/reports/compliance.csv?from=&to=streams a DENR-style dumpsite-release export,POST /admin/reports/rebuildtriggers aggregation on demand.
- Module 13b: Notifications — complete
notification_preferencestable +notifications(Laravel inbox).App\Notifications\Channels\SmsChanneladapts our SmsService.RoutesByPreferencestrait routes via database + sms based on per-user toggles. Notifications:HouseholdApproved,HouseholdRejected,QrBalanceLowNotification,CodesPurchased. Listeners auto-route via Laravel 11 discovery; rejection + purchase notifications dispatched directly from the controller /StoreOperations.GET/PATCH /me/notification-preferences. Defaults seeded on register.
- Module 13c: Payments — scaffolded (PayMongo + Manual)
paymentstable.PaymentDriverinterface withManualPaymentDriver(admin marks paid; default) andPayMongoDriver(activates whenPAYMONGO_SECRET_KEYis set; HMAC-SHA256 webhook verification).- Resident
POST /me/payments/code-purchase→ driver returns checkout URL. WebhookPOST /api/v1/webhooks/paymongoapplies paid/failed events. AdminPOST /admin/live/payments/{uuid}/mark-paidforces fulfillment for cash-paid or stuck payments. - Fulfillment runs
StoreOperations::sellToHousehold()— codes allocated → active for the household +CodesPurchasednotification.
- Module 13d: Live Tracking (HTTP polling) — complete
truck_location_history(POINT 4326 + SPATIAL INDEX).TruckTracker::record()writes history, updatestrucks.last_known_coordinates, Redis-caches latest position, flags geofence-trigger when entering the active trip's dumpsite.- Driver
POST /driver/trucks/{uuid}/location(15-second cadence; only the assigned-team driver may post). - Admin
GET /admin/live/trucksreturns positions for active trucks updated within the last hour. - Reverb broadcast deferred — polling is the data source for now.
- Flow corrections from this batch:
QrAllocator::allocateFreeToHousehold()is now idempotent — if the household already has any QR codes assigned, the listener skips. Re-approving a rejected→approved cycle no longer dispenses a second free batch.arrive-dumpsitevalidates the GPS is inside the dumpsite boundary viaDumpsite::containsPoint.override_geofence: truebypasses for known-bad GPS — admin can audit later.
- Hardening sprint (post-Module 13)
- API docs via
dedoc/scrambleat/docs/api. Configured to scope toapi/v1/*. Linked from the Settings sidebar group. - Scheduled commands registered in
routes/console.php:reports:aggregate(02:00),qr:expire(02:30),trucks:prune-locations(03:00). All idempotent +withoutOverlapping(). qr:expireflips QR codes whoseexpires_atis past fromallocated/activetoexpired.--dryflag for safe inspection.trucks:prune-locationschunk-deletestruck_location_historyolder thanconfig('verde.location_retention_days', 7).- Trip double-booking validation in
AdminTripController::store: rejects new trips when the team or truck already has a non-cancelled trip on the same date.override_conflicts: truebypasses for emergencies. Cancelled trips don't block rebooking. - Email verification flow:
User implements MustVerifyEmail,VerifyEmailNotificationoverridesverificationUrl()to use theapi.v1.auth.verification.verifynamed route. Register sends the link automatically (best-effort).POST /auth/email/resend(auth)GET /auth/email/verify/{id}/{hash}(signed URL).
- Password change while logged in:
POST /me/passwordvalidatescurrent_password, requires the new password to differ, revokes every other active token on success. - PickupImminent notification: when a
TripStopflips toarrived,TripExecutor::notifyAssignedHouseholds()finds every household whoseassigned_drop_off_point_idmatches and sends them the notification (DB + SMS). - Auto-geofence on truck location:
TruckTracker::record()now callsTripExecutor::arriveAtDumpsite()when an in-progress trip's truck pings inside its dumpsite boundary. The executor's status guard prevents duplicate timeline events when the driver also presses arrive-dumpsite manually.
- API docs via
- Final hardening sprint
- Sentry wired via
sentry/sentry-laravel.bootstrap/app.phpcallsSentry\Laravel\Integration::handles($exceptions)— silent no-op whenSENTRY_LARAVEL_DSNis empty (dev/testing). - Broader audit logging —
LogsActivitynow applied toHousehold,QrCode,Trip,Payment(in addition toUser), each with a tightlogOnlywhitelist +useLogName(...)so the activity stream stays useful and doesn't blow up storage. - Bulk admin actions —
POST /admin/households/bulk-approve(skips already-approved / no-proof / unknown ids; reports per-id outcome),POST /admin/bulk/users/{suspend,activate}(admins are protected),POST /admin/bulk/qr-codes/void(with reason; respects state machine transitions). - FCM push scaffold —
PushDriverinterface withLogPushDriver(default) andFcmPushDriver(activates whenFCM_SERVER_KEYis set).PushChanneladapter routes notifications viaRoutesByPreferenceswhenpush_enabledandfcm_tokenare present.toPush()payloads added to HouseholdApproved / QrBalanceLow / PickupImminent. - Reverb WebSocket broadcast —
laravel/reverbinstalled, channel auth inroutes/channels.php.TruckLocationBroadcastfires on everyTruckTracker::record()call. Admins subscribe toprivate-admin.live; residents in a service area subscribe toarea.{id}.trucks(channel auth checks barangay coverage). - PSGC seeder expanded — all 17 PH regions, the 4 NCR districts, all 17 NCR cities. Sample QC/Manila/Makati barangays still carry rectangular boundaries for point-in-polygon tests.
- Conditional
SPATIAL INDEX(boundary)migration — checks for null boundaries and is a safe no-op until the full PSA dataset is imported. After import, re-runningphp artisan migrateflips the column toNOT NULLand creates the index.
- Sentry wired via
- All 196 feature tests passing
- Genuinely "next milestone" items (out of code scope): full PSA
dataset file ingestion, Sentry DSN provisioning, Firebase project
- service-account key, Reverb hosting setup (
reverb:start)
- service-account key, Reverb hosting setup (
- Customer site backend gaps closed (for the Next.js resident app)
GET /partner-stores/nearby?lat&lng&radius_km— public, residents browse active stores ranked by distance viaST_Distance_SphereGET /partner-stores/{uuid}— public store detailsGET /me/collections— paginated history of resident's QR scansGET /me/upcoming-pickups— scheduled/in-progress trips that include the resident's assigned DOPGET /me/notifications(paginated, supportsunread_only=1) +POST /me/notifications/{id}/read+POST .../mark-all-read+GET .../unread-countconfig/cors.phpconfigured:CUSTOMER_APP_URL+EXTRA_CORS_ORIGINSenv vars allow the Next.js site to call the API with credentials.
- All 202 feature tests passing
Geo notes
- Boundary polygons + centroids stored nullable for now. Once a full PSGC
dataset is loaded, add a follow-up migration that makes
boundaryNOT NULL and createsSPATIAL INDEX(boundary)(MySQL requires NOT NULL for spatial indexes). ST_Contains works without the spatial index, just slower. - For NCR (no provinces in PSGC), the 4 NCR districts are modeled as virtual provinces under the NCR region. This keeps the 4-level hierarchy uniform.
Default Admin (after db:seed)
- email:
admin@verde.local - password:
password - phone:
+639000000000