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 -mThe -m flag creates a migration alongside the model. Generated files:
app/Models/WidgetAnalyticsEvent.phpdatabase/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=findersStep 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
$fillableexplicitly — never use$guarded = []. - Cast JSON columns to
'array'or useAsCollection::classfor richer objects. - Declare timestamps via
$castswhen they appear in query results or API responses.
Step 4 — Create the controller
php artisan make:controller WidgetAnalyticsEventControllerGenerated 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-Idrequest 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+auditgroup. - Premium-only — nest inside
Route::middleware('subscription_tier:premium')->group(...). - Admin-only — nest inside the
role:admingroup or under the/adminprefix. - Public / widget-callable — under the
extprefix, noauth: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-eventsCheck:
- Expected JSON shape returned.
- 401 with no token; 403 for unauthorized resources.
- Audit log row appears in
audit_logstable 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 WidgetAnalyticsEventTestPlace 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 WidgetAnalyticsEventTestStep 9 — Deploy
cd "/Users/codydavis/Local Sites/dropafinder-app-backend"
make deploymake 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
$fillabledeclaration →MassAssignmentExceptionon firstcreate()call. - No index on a foreign key used in
WHERE→ slow queries at scale. auditmiddleware 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
- Wire a dashboard UI for this endpoint: add-feature-frontend
- Expose data to the widget: add-feature-widget
- Ship to production: deploy
- Full end-to-end worked example: add-api-endpoint-end-to-end