Zum Hauptinhalt springen
tsecurity.de LIVE
Echtzeit-Radar & Feeds
Alle RSS Feeds
👥 Community & Social
IT Security NachrichtenKI-Agenten hebeln klassisches IT-Asset-Management aus(24.09.2026 um 07:14 Uhr)
IT Security NachrichtenMicrosoft erneuert Surface Pro und Laptop mit Snapdragon X2 Plus(24.09.2026 um 07:42 Uhr)
IT Security DownloadsGitHub Release: cline/cline vsdk/shared/v0.0.86 (24.09.2026)(24.09.2026 um 07:43 Uhr)
IT Security DownloadsGitHub Release: cline/cline vsdk/llms/v0.0.86 (24.09.2026)(24.09.2026 um 07:43 Uhr)
IT Security DownloadsGitHub Release: cline/cline vsdk/agents/v0.0.86 (24.09.2026)(24.09.2026 um 07:43 Uhr)
IT Security DownloadsGitHub Release: cline/cline vsdk/core/v0.0.86 (24.09.2026)(24.09.2026 um 07:43 Uhr)
IT Security DownloadsGitHub Release: cline/cline vcli-v3.0.65 (24.09.2026)(24.09.2026 um 07:54 Uhr)
IT Security NachrichtenKI-Agenten hebeln klassisches IT-Asset-Management aus(24.09.2026 um 07:14 Uhr)
IT Security NachrichtenMicrosoft erneuert Surface Pro und Laptop mit Snapdragon X2 Plus(24.09.2026 um 07:42 Uhr)
IT Security DownloadsGitHub Release: cline/cline vsdk/shared/v0.0.86 (24.09.2026)(24.09.2026 um 07:43 Uhr)
IT Security DownloadsGitHub Release: cline/cline vsdk/llms/v0.0.86 (24.09.2026)(24.09.2026 um 07:43 Uhr)
IT Security DownloadsGitHub Release: cline/cline vsdk/agents/v0.0.86 (24.09.2026)(24.09.2026 um 07:43 Uhr)
IT Security DownloadsGitHub Release: cline/cline vsdk/core/v0.0.86 (24.09.2026)(24.09.2026 um 07:43 Uhr)
IT Security DownloadsGitHub Release: cline/cline vcli-v3.0.65 (24.09.2026)(24.09.2026 um 07:54 Uhr)
Intelligence View
⚡ tsecurity.de Intelligence

Building Modern Laravel APIs: Ingesting Leads

This is where Pulse-Link starts to feel real. In the previous two articles we laid the foundation - project structure, routing, versioning, and Scribe for documentation. Now we are going to build the first actual feature: lead…

0
↗ Quelle (dev.to)
Reagiere als Erste:r — dein Feedback zählt!

This is where Pulse-Link starts to feel real. In the previous two articles we laid the foundation - project structure, routing, versioning, and Scribe for documentation. Now we are going to build the first actual feature: lead ingestion.



By the end of this article, the stub controllers are gone. We will have a working endpoint that accepts a lead payload, validates it, transforms it into a typed DTO, runs it through an Action, and returns a JSON:API formatted response. Every layer of the architecture we talked about in Article 1 will have real code in it.



Let me walk you through how it fits together.






The Shape of a Lead Ingestion Request



Before writing a single class, it is worth being clear about what a valid lead ingestion request looks like. A client sends a JSON body to POST /v1/leads with the raw lead data. Some fields are required - email, first name, last name, and the source of the lead. Everything else is optional context that enriches the record if it is present.




{
"email": "[email protected]",
"first_name": "Jane",
"last_name": "Smith",
"company": "Acme Corp",
"job_title": "Head of Engineering",
"phone": "+441234567890",
"source": "web-form"
}






That shape drives everything we are about to build. The Form Request validates it. The payload DTO carries it. The Action persists it. The JSON:API resource serialises the result.






The Payload DTO



I always start with the DTO. It is the simplest class in the chain and defining it first forces clarity about what data actually needs to move between layers.



Create app/Http/Payloads/Leads/StoreLeadPayload.php:




<?php

declare(strict_types=1);

namespace App\Http\Payloads\Leads;

final readonly class StoreLeadPayload
{
public function __construct(
public string $email,
public string $firstName,
public string $lastName,
public ?string $company,
public ?string $jobTitle,
public ?string $phone,
public string $source,
) {}

public function toArray(): array
{
return [
'email' => $this->email,
'first_name' => $this->firstName,
'last_name' => $this->lastName,
'company' => $this->company,
'job_title' => $this->jobTitle,
'phone' => $this->phone,
'source' => $this->source,
];
}
}






A few things I want to highlight here. The class is final and readonly - it cannot be extended and its properties cannot be mutated after construction. That is exactly what we want from a DTO. The data goes in once, through the constructor, and stays consistent for the lifetime of the object.



The property names use camelCase (firstName, jobTitle) while toArray() maps back to snake_case for persistence. This keeps the PHP side of the code idiomatic while respecting the database column naming convention. It is a small thing, but it removes the jarring mismatch you get when you pepper PHP code with snake_case property names everywhere.






The Form Request



The Form Request has two jobs: validate the incoming payload and produce the typed DTO. Neither job bleeds into the other.



Create app/Http/Requests/Leads/V1/StoreLeadRequest.php:




<?php

declare(strict_types=1);

namespace App\Http\Requests\Leads\V1;

use App\Http\Payloads\Leads\StoreLeadPayload;
use Illuminate\Foundation\Http\FormRequest;

final class StoreLeadRequest extends FormRequest
{
public function rules(): array
{
return [
'email' => ['required', 'string', 'email', 'max:255'],
'first_name' => ['required', 'string', 'max:255'],
'last_name' => ['required', 'string', 'max:255'],
'company' => ['nullable', 'string', 'max:255'],
'job_title' => ['nullable', 'string', 'max:255'],
'phone' => ['nullable', 'string', 'max:50'],
'source' => ['required', 'string', 'max:255'],
];
}

public function payload(): StoreLeadPayload
{
return new StoreLeadPayload(
email: $this->string('email')->toString(),
firstName: $this->string('first_name')->toString(),
lastName: $this->string('last_name')->toString(),
company: $this->string('company')->toString() ?: null,
jobTitle: $this->string('job_title')->toString() ?: null,
phone: $this->string('phone')->toString() ?: null,
source: $this->string('source')->toString(),
);
}
}






The payload() method is what separates this from a standard Laravel Form Request. Once validation passes, the controller calls $request->payload() and gets back a StoreLeadPayload instance. From that point on, nothing in the application needs to know about the raw request - it works with the typed DTO.



Notice I am using $this->string() to retrieve values rather than $this->input(). The string() helper returns a Stringable instance, which means we get the fluent string API without having to wrap values manually. Calling ->toString() at the end gives us a plain PHP string. For nullable fields, we coerce an empty Stringable to null with the ternary.



One question that comes up with this pattern: what about the authorize() method? For now it returns true - we are relying on the auth:api middleware at the route level to handle authentication. The authorize() method on a Form Request is for more granular policy checks. We will come back to that when we cover role-based access later in the series.






The Action



The IngestLead action has one job: take a StoreLeadPayload and persist it as a Lead. That is it. No validation, no HTTP concerns, no response formatting.



Create app/Actions/Leads/IngestLead.php:




<?php

declare(strict_types=1);

namespace App\Actions\Leads;

use App\Http\Payloads\Leads\StoreLeadPayload;
use App\Models\Lead;

final readonly class IngestLead
{
public function handle(StoreLeadPayload $payload): Lead
{
return Lead::query()->create([
...$payload->toArray(),
'raw_payload' => $payload->toArray(),
'status' => 'pending',
'score' => 0,
]);
}
}






The raw_payload column stores the original data exactly as it arrived. This becomes valuable in Article 9 when we build the enrichment pipeline - if enrichment fails or produces unexpected results, we can always reprocess from the original payload without losing anything.



Setting status to pending and score to 0 here is deliberate. These are not values the client sends - they are the initial state Pulse-Link assigns to every new lead. The client provides the raw data. Pulse-Link decides the initial processing state.



The final readonly combination on the class is a pattern worth following across all Actions. final prevents inheritance - there is no reason to extend a single-purpose action class. readonly signals that the class holds no mutable state. Actions that depend on services get them via constructor injection, and those dependencies are set once and never changed.






The JSON:API Resource



Laravel 13 ships with a first-class JsonApiResource class that handles the JSON:API structure for us. No manual id, type, and attributes wiring - just define your attributes and the framework handles the rest, including setting the Content-Type header to application/vnd.api+json automatically.



Generate it with the --json-api flag:




php artisan make:resource Leads/V1/LeadResource --json-api






This creates a class extending Illuminate\Http\Resources\JsonApi\JsonApiResource. Update it at app/Http/Resources/Leads/V1/LeadResource.php:




<?php

declare(strict_types=1);

namespace App\Http\Resources\Leads\V1;

use App\Models\Lead;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\JsonApi\JsonApiResource;

/**
* @property-read Lead $resource
*/

final class LeadResource extends JsonApiResource
{
public function toAttributes(Request $request): array
{
return [
'email' => $this->resource->email,
'first_name' => $this->resource->first_name,
'last_name' => $this->resource->last_name,
'company' => $this->resource->company,
'job_title' => $this->resource->job_title,
'phone' => $this->resource->phone,
'source' => $this->resource->source,
'score' => $this->resource->score,
'status' => $this->resource->status,
'created_at' => $this->resource->created_at,
'updated_at' => $this->resource->updated_at,
];
}
}






There is quite a bit less boilerplate here compared to a standard JsonResource. The type is derived automatically from the class name - LeadResource becomes leads. The id resolves from the model's primary key. The data wrapping, the attributes nesting, the content type header - all handled by the framework.



The toAttributes() method replaces the old toArray() approach. It returns only the attributes that live inside the attributes object in the JSON:API response. The result looks like this:




{
"data": {
"id": "01JMKP8R2NQZ9F0XVZYS7TDCHK",
"type": "leads",
"attributes": {
"email": "[email protected]",
"first_name": "Jane",
"last_name": "Smith",
"company": "Acme Corp",
"job_title": null,
"phone": null,
"source": "web-form",
"score": 0,
"status": "pending",
"created_at": "2026-04-10T09:00:00.000000Z",
"updated_at": "2026-04-10T09:00:00.000000Z"
}
}
}






One thing worth updating in AppServiceProvider now that we are using JsonApiResource: remove the JsonResource::withoutWrapping() call. The JsonApiResource class manages its own response structure and the withoutWrapping() call can interfere with it.




public function boot(): void
{
Model::shouldBeStrict(! app()->isProduction());
}






The @property-read Lead $resource docblock is still worth keeping for IDE autocompletion. A small quality-of-life detail that pays off when you have a dozen resource classes open at once.






Putting It Together: The StoreController



Now we can replace the stub with the real thing.



Update app/Http/Controllers/Leads/V1/StoreController.php:




<?php

declare(strict_types=1);

namespace App\Http\Controllers\Leads\V1;

use App\Actions\Leads\IngestLead;
use App\Http\Requests\Leads\V1\StoreLeadRequest;
use App\Http\Resources\Leads\V1\LeadResource;
use Illuminate\Http\JsonResponse;

/**
* @group Leads
*/

final readonly class StoreController
{
public function __construct(
private IngestLead $ingestLead,
) {}

/**
* Ingest a lead
*
* Accepts raw lead data, validates it, and queues it for AI enrichment.
*
* @bodyParam email string required The lead's email address. Example: [email protected]
* @bodyParam first_name string required The lead's first name. Example: Jane
* @bodyParam last_name string required The lead's last name. Example: Smith
* @bodyParam company string The lead's company. Example: Acme Corp
* @bodyParam job_title string The lead's job title. Example: Head of Engineering
* @bodyParam phone string The lead's phone number. Example: +441234567890
* @bodyParam source string required The origin of this lead. Example: web-form
*
* @response 201 scenario="Created" {"data": {"id": "01JMKP8R2NQZ9F0XVZYS7TDCHK", "type": "leads", "attributes": {"email": "[email protected]", "score": 0, "status": "pending"}}}
* @response 422 scenario="Validation error" {"type": "https://httpstatuses.com/422", "title": "Unprocessable Entity", "status": 422, "detail": "The given data was invalid.", "errors": {"email": ["The email field is required."]}}
* @response 401 scenario="Unauthenticated" {"type": "https://httpstatuses.com/401", "title": "Unauthenticated", "status": 401, "detail": "You are not authenticated."}
*/

public function __invoke(StoreLeadRequest $request): JsonResponse
{
$lead = $this->ingestLead->handle(
payload: $request->payload(),
);

return (new LeadResource($lead))
->response()
->setStatusCode(201);
}
}






The controller stays thin. Three lines of meaningful logic - receive the validated request, call the action with the typed payload, return the resource response.



The ->response()->setStatusCode(201) chain is worth understanding. JsonApiResource extends JsonResource, so calling ->response() returns a ResourceResponse that correctly handles the JSON:API structure and sets the Content-Type: application/vnd.api+json header. We then set the status code to 201 to signal that this was a creation rather than a retrieval. Laravel's container resolves IngestLead via the constructor automatically - no service provider registration needed.






The Index and Show Controllers



While we are here, let's complete the other two controllers so they are no longer stubs. The IndexController returns a paginated collection, and the ShowController returns a single lead.



Update app/Http/Controllers/Leads/V1/IndexController.php:




<?php

declare(strict_types=1);

namespace App\Http\Controllers\Leads\V1;

use App\Http\Resources\Leads\V1\LeadResource;
use App\Models\Lead;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;

/**
* @group Leads
*/

final class IndexController
{
/**
* List leads
*
* Returns a paginated list of leads ordered by score descending.
*
* @queryParam page integer Page number. Example: 1
* @queryParam per_page integer Results per page (max 100). Example: 25
* @queryParam status string Filter by status. Example: pending
*
* @response 200 scenario="Success" {"data": [], "links": {"first": "...", "last": "...", "prev": null, "next": null}, "meta": {"current_page": 1, "per_page": 25, "total": 0, "last_page": 1}}
* @response 401 scenario="Unauthenticated" {"type": "https://httpstatuses.com/401", "title": "Unauthenticated", "status": 401, "detail": "You are not authenticated."}
*/

public function __invoke(Request $request): AnonymousResourceCollection
{
$leads = Lead::query()
->when(
$request->string('status')->isNotEmpty(),
fn ($query) => $query->where('status', $request->string('status')->toString()),
)
->orderByDesc('score')
->paginate($request->integer('per_page', 25));

return LeadResource::collection($leads);
}
}






Update app/Http/Controllers/Leads/V1/ShowController.php:




<?php

declare(strict_types=1);

namespace App\Http\Controllers\Leads\V1;

use App\Http\Resources\Leads\V1\LeadResource;
use App\Models\Lead;

/**
* @group Leads
*/

final class ShowController
{
/**
* Get a lead
*
* Returns a single lead by its ULID.
*
* @urlParam lead string required The lead ULID. Example: 01JMKP8R2NQZ9F0XVZYS7TDCHK
*
* @response 200 scenario="Success" {"data": {"id": "01JMKP8R2NQZ9F0XVZYS7TDCHK", "type": "leads", "attributes": {"email": "[email protected]", "score": 0, "status": "pending"}}}
* @response 404 scenario="Not found" {"type": "https://httpstatuses.com/404", "title": "Not Found", "status": 404, "detail": "The requested resource was not found."}
* @response 401 scenario="Unauthenticated" {"type": "https://httpstatuses.com/401", "title": "Unauthenticated", "status": 401, "detail": "You are not authenticated."}
*/

public function __invoke(Lead $lead): LeadResource
{
return new LeadResource($lead);
}
}






The ShowController uses Laravel's implicit route model binding - the Lead $lead type hint in __invoke tells the framework to resolve the model from the {lead} route parameter automatically. If the ULID does not match a record, Laravel throws a ModelNotFoundException, which our Problem+JSON exception handler in bootstrap/app.php catches and returns as a clean 404 response.



The IndexController uses a when() clause to apply the status filter only when the parameter is present and non-empty. No conditional blocks, no if statements - just a clean fluent query that builds itself based on the request state.






Testing the Endpoint



Let's run a quick manual test to confirm everything is wired up. First, run the migrations if you have not already:




php artisan migrate






Then start the server:




php artisan serve






Send a POST request without a token to confirm Problem+JSON is working:




curl -X POST http://localhost:8000/v1/leads \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]"}'






You should get a 401 Problem+JSON response. With a valid token, send an incomplete payload:




curl -X POST http://localhost:8000/v1/leads \
-H "Content-Type: application/json" \
-H "Authorization: Bearer {your-token}" \
-d '{"email": "[email protected]"}'






This should return a 422 with the missing field errors in the errors object. And with a complete payload:




curl -X POST http://localhost:8000/v1/leads \
-H "Content-Type: application/json" \
-H "Authorization: Bearer {your-token}" \
-d '{
"email": "[email protected]",
"first_name": "Jane",
"last_name": "Smith",
"company": "Acme Corp",
"source": "web-form"
}'







You should get a 201 with the JSON:API formatted lead resource:




{
"data": {
"id": "01JMKP8R2NQZ9F0XVZYS7TDCHK",
"type": "leads",
"attributes": {
"email": "[email protected]",
"first_name": "Jane",
"last_name": "Smith",
"company": "Acme Corp",
"job_title": null,
"phone": null,
"source": "web-form",
"score": 0,
"status": "pending",
"created_at": "2026-04-10T09:00:00.000000Z",
"updated_at": "2026-04-10T09:00:00.000000Z"
}
}
}






Notice the Content-Type: application/vnd.api+json response header - that comes from JsonApiResource automatically. No extra configuration needed.



Then regenerate the Scribe docs to confirm they reflect the real implementation:




php artisan scribe:generate






The documentation now reflects the actual Form Request validation rules, the real response shape from the LeadResource, and the examples from the controller annotations.






What We Have Built



Three articles in and Pulse-Link is accepting real leads. Here is what the full ingestion flow looks like end-to-end:



The request hits POST /v1/leads. The auth:api middleware validates the JWT. StoreLeadRequest validates the payload and produces a StoreLeadPayload DTO via payload(). The controller passes that DTO to IngestLead::handle(). The action persists the lead with initial pending status and a 0 score, storing the raw payload for future reprocessing. The LeadResource serialises the result into a JSON:API response. The controller returns it with a 201 status.



Every layer has exactly one job. Nothing bleeds into anything else. The architecture we described in Article 1 is now real code.



In the next article we are going to add JWT authentication properly - registration, login, token refresh, and protecting routes beyond the basic auth:api middleware we already have in place.






Next: Authentication with JWT - implementing registration, login, and token refresh for Pulse-Link using php-open-source-saver/jwt-auth.

SOC Incident Playbook: Remote Code Execution (RCE) Defense
title: Detect Exploitation - Building Modern Laravel APIs: Ingesting Leads
id: 3ba1ede1-308b-4984-b213-74649b06af27
status: experimental
description: Automatisch generierte SIEM-Erkennungsregel basierend auf CTI Intelligence
references:
  - https://tsecurity.de/
author: iShareStuff CTI Automated Detection Engine
date: 2026-09-24
logsource:
  category: network_connection
  product: any
detection:
  selection:
      CommandLine|contains:
        - 'exploit'
  condition: selection
falsepositives:
  - Legitime administrative Zugriffe oder Penetrationstests
level: high
tags:
  - attack.initial_access
rule CTI_Threat_Indicator {
    meta:
        author = "iShareStuff CTI Automated Detection Engine"
        date = "2026-09-24"
        description = "YARA Signature for "
    strings:
        $str = "Building Modern Laravel APIs: " ascii wide
    condition:
        any of them
}
tsecurity.de Cognitive Threat RAG
Fokus-Vektor:

Kognitive Analyse für identifizierte Bedrohung: Erhöhte Bedrohungslage im Bereich Building Modern Laravel APIs: Ingesting .... Basierend auf 368k Vektor-Korrelationen werden sofortige Isolationsmaßnahmen für betroffene Endpunkte empfohlen.

🛡️ Angriffsfläche & Exposure

Netzwerk/Remote-Zugriff ohne Vorauthentifizierung möglich.

Empfohlene Sofortmaßnahmen
  • 1. Perimeter-Inspektion: Relevante Portfreigaben und exponierte Endpunkte unverzüglich scannen.
  • 2. Patch-Applikation: Hersteller-Hotfix einspielen oder betroffene Daemons in isolierte DMZ-Segmente überführen.
  • 3. Telemetrie & EDR-Alerts: Prozessaufrufe und Child-Processes auf anomale Shell-Spawns überwachen.
🔗 Semantisch verwandte Zero-Days MariaDB 11.7 VEC
Ähnliche Beiträge
🔍 Verwandte News

Auch interessante Nachrichten Building Modern Laravel APIs: Ingesting Leads

Thematisch verwandte Begriffe: Building, Modern, Laravel, APIs · 6 Treffer

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Zum Aktualisieren ziehen
ZERO-DAY CVE-2026-96676 | A vulnerability was identified in Fast FAC1900R 20190827_2.0.2. The impa…
Advisory →
TTS Reader • tsecurity.de Voice
tsecurity.de Icon
tsecurity.de App
Offline-Lesen, Eilmeldungen & 0ms Ladezeit

Installiere tsecurity.de direkt auf deinen Home-Bildschirm für das ultimative Vollbild-Magazinerlebnis ohne Browser-Leisten.

Nächster Beitrag
Themen-Radar & Intelligence Matrix
Echtzeit-Taxonomie nach Angriffsvektoren & Plattformen

tsecurity.de Live Threat Radar

🔴 LIVE RADAR
MONITORING
AKTIV
CVE-DATENBANK
LIVE
🔍
Community Radar & Live Chat
Sentinel Bot online • Live-Stream
Dein Cluster: Security Explorer
Match:
lädt…
Verbindung zum Community-Stream wird aufgebaut...
Bearbeitungsmodus — Senden überschreibt deine Nachricht
Community-Puls — was gerade passiert
lädt…
Aktivitäten deiner Analysten
lädt…
Neues Thema oder Eilmeldung einreichen

Reiche interessante Links, Zero-Days oder Debatten ein. Die Community entscheidet per Upvote über die Veröffentlichung.

Heiß diskutierte Einreichungen
🔖 Gespeicherte Artikel
📂 Keine gespeicherten Artikel vorhanden.
Zurück Ziehen Vor
Links: vorheriger Artikel Rechts: nächster Artikel unten: schließen
News NIS-2 Frühwarnung Tier-1 Intel TTP ⏱️ 3 Min vor 10 Min
Artikeldaten werden geladen...

Zurück: vorheriger Vor: nächster
↗ Original-Quelle
Social Reaktionen Deine Reaktion zählt
Einstufung & Relevanz-Poll 0 Stimmen
In sozialen Netzwerken teilen 1-Klick