These docs are a work in progress and may not be fully up to date. Some pages may contain internal notes for our team.
Skip to Content

How to — add a feature (backend)

Focused recipe for adding a new backend resource or extending an existing one. For features that also require dashboard or widget changes, treat this page as Step 1 and cross-reference add-feature-frontend and add-feature-widget. For the full end-to-end walkthrough, see add-api-endpoint-end-to-end.

Step 1 — Generate the model and migration

From dropafinder-app-backend/:

php artisan make:model WidgetAnalyticsEvent -m

The -m flag creates a migration alongside the model. Generated files:

  • app/Models/WidgetAnalyticsEvent.php
  • database/migrations/YYYY_MM_DD_HHMMSS_create_widget_analytics_events_table.php

If you only need to extend an existing model’s table (add a column), create a migration directly:

php artisan make:migration add_locale_to_finders_table --table=finders

Step 2 — Write the migration

Edit the generated migration in database/migrations/. Include indexes for any column that will be queried in a WHERE clause:

Schema::create('widget_analytics_events', function (Blueprint $table) { $table->id(); $table->foreignId('finder_id')->constrained()->cascadeOnDelete(); $table->string('event_type', 64); $table->json('event_data')->nullable(); $table->timestamp('occurred_at'); $table->timestamps(); $table->index(['finder_id', 'occurred_at']); });

Run locally:

php artisan migrate

⚠️ Warning: Never drop or rename a column in the same migration as adding one. Use two separate migrations — the first additive (safe to deploy), the second destructive (coordinate with consumers first).

Step 3 — Configure the model

In app/Models/WidgetAnalyticsEvent.php:

class WidgetAnalyticsEvent extends Model { protected $fillable = ['finder_id', 'event_type', 'event_data', 'occurred_at']; protected $casts = [ 'event_data' => 'array', 'occurred_at' => 'datetime', ]; public function finder() { return $this->belongsTo(Finder::class); } }

Conventions:

  • Always declare $fillable explicitly — never use $guarded = [].
  • Cast JSON columns to 'array' or use AsCollection::class for richer objects.
  • Declare timestamps via $casts when they appear in query results or API responses.

Step 4 — Create the controller

php artisan make:controller WidgetAnalyticsEventController

Generated at app/Http/Controllers/WidgetAnalyticsEventController.php. Add the methods you need — typically index, show, store, update, destroy. Return resources via response()->json([...]) with a data envelope:

public function index(Request $request) { $workspaceId = $request->header('X-Workspace-Id'); $events = WidgetAnalyticsEvent::whereHas('finder', fn($q) => $q->where('workspace_id', $workspaceId) )->latest('occurred_at')->paginate(50); return response()->json(['data' => $events]); }

Key conventions (from patterns in FinderController.php):

  • Use findOrFail($id) for single-resource lookups — auto-returns 404.
  • Authorize via Laravel policies: $this->authorize('view', $event).
  • Scope to the active workspace via the X-Workspace-Id request header.
  • Mutating actions: validate with $request->validate([...]) before saving.

Step 5 — Register the route

Open dropafinder-app-backend/routes/api.php. Place the new routes in the right middleware group:

// Inside Route::middleware(['auth:api', 'auth_session', 'audit'])->group(...) Route::get('/widget-analytics-events', [WidgetAnalyticsEventController::class, 'index']); Route::get('/widget-analytics-events/{id}', [WidgetAnalyticsEventController::class, 'show']);

Group guide:

  • Authenticated user (most features) — inside auth:api + auth_session + audit group.
  • Premium-only — nest inside Route::middleware('subscription_tier:premium')->group(...).
  • Admin-only — nest inside the role:admin group or under the /admin prefix.
  • Public / widget-callable — under the ext prefix, no auth:api.

The audit middleware logs every mutating action automatically. Keep it in the chain for POST, PUT, PATCH, and DELETE routes; it has no cost on reads.

Step 6 — Add to the admin section (if applicable)

If the resource should be viewable in the Admin Console, add the relevant methods to app/Http/Controllers/AdminConsoleController.php and wire routes under the /admin prefix in routes/api.php.

Step 7 — Run and verify locally

php artisan serve curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/api/widget-analytics-events

Check:

  • Expected JSON shape returned.
  • 401 with no token; 403 for unauthorized resources.
  • Audit log row appears in audit_logs table after mutating requests.

Step 8 — Write tests

PHPUnit is configured in phpunit.xml with tests/Feature/ and tests/Unit/ suites. Add a feature test that hits the route under real middleware:

php artisan make:test WidgetAnalyticsEventTest

Place the test at tests/Feature/WidgetAnalyticsEventTest.php. See the existing tests in tests/Feature/ (e.g., AuditLogTest.php, LocationImageTest.php) for structural patterns.

Run the suite:

./vendor/bin/phpunit # or with a filter ./vendor/bin/phpunit --filter WidgetAnalyticsEventTest

Step 9 — Deploy

cd "/Users/codydavis/Local Sites/dropafinder-app-backend" make deploy

make deploy SSHs to the cloudways-dropafinder host and runs migrate --force, config:cache, route:cache, and view:cache. See deploy for the full deploy ritual.

Common mistakes

  • Missing $fillable declarationMassAssignmentException on first create() call.
  • No index on a foreign key used in WHERE → slow queries at scale.
  • audit middleware dropped on a mutation route → no audit log row, harder to debug later.
  • Forgot to scope to workspace → data leaks across workspaces.
  • Returning raw model attributes → snake_case leaks into the dashboard; always keep wire format consistent (normalizers live in the dashboard, but consistent snake_case on the wire is the contract).

Where to next