EDI (Electronic Data Interchange) is the backbone of enterprise commerce, processing trillions in B2B transactions annually. Yet most developers treat it like black magic. After building EDI systems that process $2B+ in logistics transactions, I'm demystifying it. This is the guide I wish I had when I started.
What EDI Really Is (And Why It Still Matters)
EDI is how enterprises talk to each other. While startups use REST APIs, Fortune 500 companies send purchase orders via EDI 850 documents, tender loads with EDI 204s, and confirm deliveries with EDI 990s.
Here's what a real EDI 204 (Load Tender) looks like:
ISA*00* *00* *ZZ*SENDER123 *ZZ*RECEIVER456 *240115*1430*U*00401*000012345*0*P*>~
GS*SM*SENDER123*RECEIVER456*20240115*1430*12345*X*004010~
ST*204*0001~
B2**SENDERLOAD123**PP~
B2A*00~
L11*CUSTOMER-PO-123*PO~
L11*PRIORITY-SHIPMENT*SI~
NTE*SPH*HANDLE WITH CARE - FRAGILE ELECTRONICS~
N1*SH*ACME ELECTRONICS*93*1234567890~
N3*123 TECH BOULEVARD~
N4*SAN JOSE*CA*95110*US~
N1*CN*BEST BUY DISTRIBUTION*93*0987654321~
N3*456 COMMERCE DRIVE~
N4*CHICAGO*IL*60601*US~
S5*1*LD*27500*L*2*PLT~
L11*TRACKING123456*CR~
SE*15*0001~
GE*1*12345~
IEA*1*000012345~Looks like garbage? That's $50,000 worth of electronics being shipped. One character wrong and the shipment fails.
The Architecture: How We Process Millions of EDI Documents
Here's the production system architecture I built at IEL:
EDI Partners → Trading Partner Network → Our System
↓ ↓ ↓
AS2/FTP Bitfreighter Laravel App
API ↓
↓ Parse & Validate
Webhook to Us ↓
↓ Business Logic
Transform ↓
↓ Database
Laravel Queue ↓
↓ Generate Response
Process & Store ↓
↓ Send via API
Update TMS ↓
Trading PartnerPart 1: Setting Up the Laravel EDI Infrastructure
Step 1: Install Dependencies and Create Structure
composer require cloak/laravel-edi
composer require guzzlehttp/guzzle
composer require league/csv
# Create EDI structure
php artisan make:model EdiDocument -m
php artisan make:model EdiPartner -m
php artisan make:model EdiTransaction -mStep 2: Database Schema for EDI
// database/migrations/create_edi_documents_table.php
Schema::create('edi_documents', function (Blueprint $table) {
$table->id();
$table->string('transaction_set', 10); // 204, 850, 990, etc
$table->string('control_number')->unique();
$table->enum('direction', ['inbound', 'outbound']);
$table->enum('status', ['received', 'parsing', 'processed', 'failed', 'acknowledged']);
$table->foreignId('partner_id')->constrained('edi_partners');
$table->text('raw_content');
$table->json('parsed_data')->nullable();
$table->json('validation_errors')->nullable();
$table->string('functional_ack_status')->nullable();
$table->timestamp('processed_at')->nullable();
$table->timestamps();
$table->index(['transaction_set', 'status']);
$table->index(['partner_id', 'created_at']);
});
// database/migrations/create_edi_partners_table.php
Schema::create('edi_partners', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('isa_id', 15)->unique(); // ISA ID Qualifier
$table->string('gs_id', 15); // GS ID
$table->enum('connection_type', ['ftp', 'sftp', 'as2', 'api', 'van']);
$table->json('connection_config'); // Encrypted credentials
$table->json('document_versions'); // {204: '004010', 850: '005010'}
$table->boolean('active')->default(true);
$table->boolean('test_mode')->default(false);
$table->json('retry_config')->nullable();
$table->timestamps();
});
// database/migrations/create_edi_transactions_table.php
Schema::create('edi_transactions', function (Blueprint $table) {
$table->id();
$table->foreignId('document_id')->constrained('edi_documents');
$table->string('business_reference'); // PO#, Load#, Invoice#
$table->string('transaction_type'); // tender, purchase_order, invoice
$table->json('business_data'); // Normalized data
$table->enum('status', ['pending', 'accepted', 'rejected', 'exception']);
$table->decimal('amount', 12, 2)->nullable();
$table->timestamp('due_date')->nullable();
$table->json('audit_trail');
$table->timestamps();
$table->index('business_reference');
$table->index(['transaction_type', 'status']);
});Part 2: The EDI Parser - Where the Magic Happens
Core Parser Class
// app/Services/EDI/EdiParser.php
namespace App\Services\EDI;
use App\Models\EdiDocument;
use App\Exceptions\EdiParsingException;
class EdiParser
{
private array $segments = [];
private array $parsed = [];
private string $elementSeparator;
private string $segmentTerminator;
public function parse(string $rawEdi): array
{
$this->detectDelimiters($rawEdi);
$this->segments = $this->splitIntoSegments($rawEdi);
// Parse envelope
$this->parsed['interchange'] = $this->parseISA();
$this->parsed['functional_group'] = $this->parseGS();
// Route to specific parser based on transaction set
$transactionSet = $this->getTransactionSet();
switch ($transactionSet) {
case '204':
return $this->parse204LoadTender();
case '850':
return $this->parse850PurchaseOrder();
case '990':
return $this->parse990Response();
case '997':
return $this->parse997Acknowledgment();
default:
throw new EdiParsingException("Unsupported transaction set: $transactionSet");
}
}
private function detectDelimiters(string $rawEdi): void
{
// EDI delimiters are in positions 3, 104, and 105 of ISA segment
$this->elementSeparator = substr($rawEdi, 3, 1); // Usually *
$this->segmentTerminator = substr($rawEdi, 105, 1); // Usually ~
}
private function parse204LoadTender(): array
{
$tender = [
'transaction_set' => '204',
'control_number' => null,
'shipment_info' => [],
'stops' => [],
'equipment' => [],
'charges' => [],
];
$currentStop = null;
foreach ($this->segments as $segment) {
$elements = explode($this->elementSeparator, $segment);
$segmentId = $elements[0];
switch ($segmentId) {
case 'ST':
$tender['control_number'] = $elements[2] ?? null;
break;
case 'B2': // Shipment Info
$tender['shipment_info'] = [
'scac' => $elements[1] ?? null,
'shipment_id' => $elements[2] ?? null,
'payment_method' => $elements[3] ?? null,
];
break;
case 'L11': // Reference Numbers
$tender['references'][] = [
'number' => $elements[1] ?? null,
'qualifier' => $elements[2] ?? null, // PO, SI, CR, etc
];
break;
case 'N1': // Name (Shipper/Consignee)
$currentStop = [
'type' => $elements[1] ?? null, // SH=Shipper, CN=Consignee
'name' => $elements[2] ?? null,
'id_qualifier' => $elements[3] ?? null,
'id' => $elements[4] ?? null,
'address' => [],
];
break;
case 'N3': // Address Line
if ($currentStop) {
$currentStop['address']['line1'] = $elements[1] ?? null;
$currentStop['address']['line2'] = $elements[2] ?? null;
}
break;
case 'N4': // City/State/Zip
if ($currentStop) {
$currentStop['address']['city'] = $elements[1] ?? null;
$currentStop['address']['state'] = $elements[2] ?? null;
$currentStop['address']['zip'] = $elements[3] ?? null;
$currentStop['address']['country'] = $elements[4] ?? null;
$tender['stops'][] = $currentStop;
$currentStop = null;
}
break;
case 'S5': // Stop Details
$tender['stops'][count($tender['stops']) - 1]['details'] = [
'stop_sequence' => $elements[1] ?? null,
'stop_reason' => $elements[2] ?? null, // LD=Load, UL=Unload
'weight' => $elements[3] ?? null,
'weight_unit' => $elements[4] ?? null,
'volume' => $elements[5] ?? null,
'volume_unit' => $elements[6] ?? null,
];
break;
case 'L3': // Charges
$tender['charges'] = [
'freight_charge' => $elements[1] ?? null,
'total_charge' => $elements[5] ?? null,
];
break;
}
}
return $tender;
}
// Similar parsers for 850, 990, 997...
}Business Logic Layer
// app/Services/EDI/EdiProcessor.php
namespace App\Services\EDI;
use App\Models\{EdiDocument, EdiTransaction, Shipment};
use Illuminate\Support\Facades\DB;
class EdiProcessor
{
private EdiParser $parser;
private EdiValidator $validator;
private EdiTransformer $transformer;
public function processInboundDocument(EdiDocument $document): void
{
DB::transaction(function () use ($document) {
try {
// Parse the raw EDI
$parsed = $this->parser->parse($document->raw_content);
$document->parsed_data = $parsed;
// Validate against business rules
$validation = $this->validator->validate($parsed);
if (!$validation['valid']) {
$document->status = 'failed';
$document->validation_errors = $validation['errors'];
$document->save();
// Send 997 rejection
$this->send997Acknowledgment($document, false, $validation['errors']);
return;
}
// Transform to business objects
$businessData = $this->transformer->transform($parsed);
// Process based on transaction type
switch ($parsed['transaction_set']) {
case '204':
$this->processLoadTender($document, $businessData);
break;
case '850':
$this->processPurchaseOrder($document, $businessData);
break;
case '990':
$this->processLoadResponse($document, $businessData);
break;
}
$document->status = 'processed';
$document->processed_at = now();
$document->save();
// Send 997 acceptance
$this->send997Acknowledgment($document, true);
} catch (\Exception $e) {
$document->status = 'failed';
$document->validation_errors = ['exception' => $e->getMessage()];
$document->save();
// Alert operations team
$this->alertOperations($document, $e);
throw $e;
}
});
}
private function processLoadTender(EdiDocument $document, array $data): void
{
// Create or update shipment
$shipment = Shipment::updateOrCreate(
['external_reference' => $data['shipment_id']],
[
'partner_id' => $document->partner_id,
'pickup_address' => $data['stops'][0]['address'],
'delivery_address' => $data['stops'][1]['address'],
'pickup_date' => $data['pickup_date'],
'delivery_date' => $data['delivery_date'],
'weight' => $data['weight'],
'freight_charge' => $data['charges']['freight_charge'],
'status' => 'tendered',
'edi_document_id' => $document->id,
]
);
// Create EDI transaction record
EdiTransaction::create([
'document_id' => $document->id,
'business_reference' => $shipment->id,
'transaction_type' => 'load_tender',
'business_data' => $data,
'status' => 'pending',
'amount' => $data['charges']['total_charge'],
'due_date' => $data['delivery_date'],
'audit_trail' => ['created' => now()->toIso8601String()],
]);
// Send to TMS for acceptance/rejection
dispatch(new ProcessLoadTender($shipment));
// Auto-accept if within parameters
if ($this->shouldAutoAccept($shipment)) {
$this->send990Acceptance($document, $shipment);
}
}
private function shouldAutoAccept(Shipment $shipment): bool
{
// Business rules for auto-acceptance
return $shipment->freight_charge <= 5000
&& $shipment->pickup_date->gt(now()->addDays(2))
&& $shipment->partner->auto_accept_enabled;
}
}Part 3: Handling EDI Responses (997 and 990)
Generating a 997 Functional Acknowledgment
// app/Services/EDI/Edi997Generator.php
namespace App\Services\EDI;
class Edi997Generator
{
public function generate(EdiDocument $original, bool $accepted, array $errors = []): string
{
$partner = $original->partner;
$controlNumber = $this->generateControlNumber();
$segments = [];
// ISA - Interchange Control Header
$segments[] = $this->buildISA($partner, $controlNumber);
// GS - Functional Group Header
$segments[] = $this->buildGS($partner, $controlNumber);
// ST - Transaction Set Header
$segments[] = "ST*997*0001";
// AK1 - Functional Group Response Header
$originalGS = $this->extractGS($original->raw_content);
$segments[] = "AK1*{$originalGS['functional_id']}*{$originalGS['control_number']}";
// AK2 - Transaction Set Response Header (if rejecting specific transaction)
if (!$accepted && !empty($errors)) {
foreach ($errors as $error) {
$segments[] = "AK2*{$error['transaction_set']}*{$error['control_number']}";
// AK3 - Data Segment Note (specific errors)
if (isset($error['segment_errors'])) {
foreach ($error['segment_errors'] as $segError) {
$segments[] = "AK3*{$segError['segment']}*{$segError['position']}*{$segError['error_code']}";
// AK4 - Data Element Note
if (isset($segError['element_errors'])) {
foreach ($segError['element_errors'] as $elemError) {
$segments[] = "AK4*{$elemError['position']}*{$elemError['error_code']}*{$elemError['bad_value']}";
}
}
}
}
// AK5 - Transaction Set Response Trailer
$segments[] = "AK5*" . ($accepted ? "A" : "R"); // A=Accepted, R=Rejected
}
}
// AK9 - Functional Group Response Trailer
$status = $accepted ? "A" : "R";
$segments[] = "AK9*{$status}*1*1*1"; // Status, # received, # accepted, # in group
// SE - Transaction Set Trailer
$segmentCount = count($segments) - 2; // Exclude ISA and GS
$segments[] = "SE*{$segmentCount}*0001";
// GE - Functional Group Trailer
$segments[] = "GE*1*{$controlNumber}";
// IEA - Interchange Control Trailer
$segments[] = "IEA*1*{$controlNumber}";
return implode("~", $segments) . "~";
}
private function buildISA($partner, $controlNumber): string
{
$now = now();
return sprintf(
"ISA*00* *00* *ZZ*%-15s*ZZ*%-15s*%s*%s*U*00401*%09d*0*%s*>",
config('edi.sender_id'),
$partner->isa_id,
$now->format('ymd'),
$now->format('Hi'),
$controlNumber,
$partner->test_mode ? 'T' : 'P'
);
}
}Sending a 990 Load Response
// app/Services/EDI/Edi990Generator.php
namespace App\Services\EDI;
class Edi990Generator
{
public function generateAcceptance(EdiDocument $original204, Shipment $shipment): string
{
$segments = [];
$controlNumber = $this->generateControlNumber();
// ... ISA, GS headers ...
// ST - Transaction Set Header
$segments[] = "ST*990*0001";
// B1 - Beginning Segment for Load Response
$segments[] = sprintf(
"B1*%s*%s*%s*A", // A=Accept, D=Decline
$shipment->external_reference,
$shipment->our_reference,
now()->format('Ymd')
);
// N9 - Reference Identification (reasons, if declining)
if ($shipment->status === 'rejected') {
$segments[] = "N9*REJ*" . $shipment->rejection_reason;
}
// Include original shipment reference
$segments[] = "N9*SI*" . $shipment->external_reference;
// SE, GE, IEA trailers...
return implode("~", $segments) . "~";
}
}Part 4: Real-Time EDI via APIs (Modern Approach)
Bitfreighter/API-Based EDI
// app/Services/EDI/BitfreighterService.php
namespace App\Services\EDI;
use GuzzleHttp\Client;
use App\Models\EdiDocument;
class BitfreighterService
{
private Client $client;
private string $apiKey;
public function __construct()
{
$this->client = new Client([
'base_uri' => config('edi.bitfreighter.url'),
'timeout' => 30,
]);
$this->apiKey = config('edi.bitfreighter.api_key');
}
public function send(EdiDocument $document): array
{
try {
$response = $this->client->post('/api/v2/documents', [
'headers' => [
'Authorization' => 'Bearer ' . $this->apiKey,
'Content-Type' => 'application/json',
],
'json' => [
'partner_id' => $document->partner->api_partner_id,
'document_type' => $document->transaction_set,
'content' => base64_encode($document->raw_content),
'test_mode' => $document->partner->test_mode,
'callback_url' => route('edi.webhook', $document->id),
],
]);
$result = json_decode($response->getBody(), true);
// Store transmission details
$document->transmission_id = $result['transmission_id'];
$document->transmitted_at = now();
$document->save();
return $result;
} catch (\Exception $e) {
Log::error('Bitfreighter transmission failed', [
'document_id' => $document->id,
'error' => $e->getMessage(),
]);
// Implement retry logic
dispatch(new RetryEdiTransmission($document))
->delay(now()->addMinutes(5));
throw $e;
}
}
public function receive(array $webhookData): EdiDocument
{
// Validate webhook signature
if (!$this->validateWebhookSignature($webhookData)) {
throw new \Exception('Invalid webhook signature');
}
// Create inbound document
return EdiDocument::create([
'transaction_set' => $webhookData['document_type'],
'control_number' => $webhookData['control_number'],
'direction' => 'inbound',
'status' => 'received',
'partner_id' => $this->findPartnerByApiId($webhookData['sender_id'])->id,
'raw_content' => base64_decode($webhookData['content']),
'transmission_id' => $webhookData['transmission_id'],
]);
}
private function validateWebhookSignature(array $data): bool
{
$signature = $data['signature'] ?? '';
$payload = json_encode($data['payload']);
$expectedSignature = hash_hmac('sha256', $payload, config('edi.bitfreighter.webhook_secret'));
return hash_equals($signature, $expectedSignature);
}
}Part 5: Production Monitoring & Error Recovery
EDI Health Dashboard
// app/Http/Controllers/EdiDashboardController.php
class EdiDashboardController extends Controller
{
public function index()
{
return view('edi.dashboard', [
'stats' => $this->getStats(),
'recentErrors' => $this->getRecentErrors(),
'partnerHealth' => $this->getPartnerHealth(),
'volumeChart' => $this->getVolumeChart(),
]);
}
private function getStats(): array
{
$today = now()->startOfDay();
return [
'documents_today' => EdiDocument::whereDate('created_at', $today)->count(),
'success_rate' => $this->calculateSuccessRate(),
'avg_processing_time' => $this->calculateAvgProcessingTime(),
'pending_acknowledgments' => EdiDocument::where('functional_ack_status', null)
->where('created_at', '>', now()->subHours(24))
->count(),
'failed_documents' => EdiDocument::where('status', 'failed')
->where('created_at', '>', now()->subHours(24))
->count(),
];
}
private function getPartnerHealth(): array
{
return EdiPartner::active()
->get()
->map(function ($partner) {
$recentDocs = $partner->documents()
->where('created_at', '>', now()->subDays(7))
->get();
return [
'partner' => $partner->name,
'total_documents' => $recentDocs->count(),
'success_rate' => $recentDocs->where('status', 'processed')->count() / max($recentDocs->count(), 1) * 100,
'avg_response_time' => $recentDocs->avg(function ($doc) {
return $doc->processed_at ? $doc->processed_at->diffInSeconds($doc->created_at) : null;
}),
'last_communication' => $partner->documents()->latest()->first()?->created_at,
];
});
}
}Error Recovery System
// app/Jobs/RetryFailedEdiDocuments.php
class RetryFailedEdiDocuments extends Job
{
public function handle()
{
$failedDocs = EdiDocument::where('status', 'failed')
->where('retry_count', '<', 3)
->where('created_at', '>', now()->subHours(24))
->get();
foreach ($failedDocs as $doc) {
// Check if error is retryable
if ($this->isRetryable($doc->validation_errors)) {
$doc->retry_count++;
$doc->status = 'received'; // Reset for reprocessing
$doc->save();
ProcessInboundEdi::dispatch($doc)
->delay(now()->addMinutes(5 * $doc->retry_count));
}
}
}
private function isRetryable($errors): bool
{
$nonRetryableErrors = [
'INVALID_PARTNER_ID',
'DUPLICATE_CONTROL_NUMBER',
'MALFORMED_STRUCTURE',
];
foreach ($nonRetryableErrors as $error) {
if (str_contains(json_encode($errors), $error)) {
return false;
}
}
return true;
}
}Part 6: Testing EDI Integrations
Unit Testing EDI Parsers
// tests/Unit/EdiParserTest.php
use Tests\TestCase;
use App\Services\EDI\EdiParser;
class EdiParserTest extends TestCase
{
private EdiParser $parser;
protected function setUp(): void
{
parent::setUp();
$this->parser = new EdiParser();
}
/** @test */
public function it_parses_204_load_tender()
{
$edi204 = file_get_contents(base_path('tests/fixtures/edi/204_sample.edi'));
$parsed = $this->parser->parse($edi204);
$this->assertEquals('204', $parsed['transaction_set']);
$this->assertEquals('SENDERLOAD123', $parsed['shipment_info']['shipment_id']);
$this->assertCount(2, $parsed['stops']); // Pickup and delivery
$this->assertEquals('27500', $parsed['stops'][0]['details']['weight']);
}
/** @test */
public function it_handles_malformed_edi_gracefully()
{
$malformedEdi = "ISA*00*BROKEN";
$this->expectException(EdiParsingException::class);
$this->parser->parse($malformedEdi);
}
/** @test */
public function it_validates_required_segments()
{
$ediMissingRequiredSegments = $this->buildEdiWithoutB2Segment();
$result = $this->parser->parse($ediMissingRequiredSegments);
$this->assertArrayHasKey('warnings', $result);
$this->assertContains('Missing required B2 segment', $result['warnings']);
}
}Integration Testing with Partners
// tests/Feature/EdiIntegrationTest.php
class EdiIntegrationTest extends TestCase
{
/** @test */
public function it_processes_complete_edi_workflow()
{
// Create test partner
$partner = EdiPartner::factory()->create([
'connection_type' => 'api',
'test_mode' => true,
]);
// Simulate inbound 204
$response = $this->postJson('/api/edi/webhook', [
'partner_id' => $partner->api_partner_id,
'document_type' => '204',
'content' => base64_encode($this->getSample204()),
'signature' => $this->generateSignature($this->getSample204()),
]);
$response->assertStatus(200);
// Verify document was created and processed
$this->assertDatabaseHas('edi_documents', [
'transaction_set' => '204',
'status' => 'processed',
'partner_id' => $partner->id,
]);
// Verify 997 was sent back
$this->assertDatabaseHas('edi_documents', [
'transaction_set' => '997',
'direction' => 'outbound',
'partner_id' => $partner->id,
]);
// Verify business object was created
$this->assertDatabaseHas('shipments', [
'partner_id' => $partner->id,
'status' => 'tendered',
]);
}
}Production Lessons Learned
1. Always Validate Before Processing
// Never trust EDI data
$validator = new EdiValidator();
$errors = $validator->validate($parsed);
if (!empty($errors)) {
// Send 997 rejection IMMEDIATELY
// Don't process bad data
}2. Idempotency Is Critical
// EDI documents can be sent multiple times
$existing = EdiDocument::where('control_number', $controlNumber)
->where('partner_id', $partnerId)
->first();
if ($existing) {
// Return previous response, don't reprocess
return $existing->response;
}3. Map Everything
// EDI codes to business logic mapping
const STOP_REASONS = [
'LD' => 'pickup',
'UL' => 'delivery',
'CL' => 'complete_load',
'CU' => 'complete_unload',
'PL' => 'partial_load',
'PU' => 'partial_unload',
];4. Monitor Partner Patterns
// Each partner has quirks
if ($partner->name === 'BIG_RETAILER') {
// They send weight in KG not LBS despite spec
$weight = $weight * 2.20462;
}The Complete EDI Command Center
// app/Console/Commands/EdiHealthCheck.php
class EdiHealthCheck extends Command
{
public function handle()
{
// Check for stuck documents
$stuck = EdiDocument::where('status', 'parsing')
->where('updated_at', '<', now()->subMinutes(10))
->count();
if ($stuck > 0) {
alert("$stuck EDI documents stuck in parsing!");
}
// Check for missing acknowledgments
$unacknowledged = EdiDocument::whereNull('functional_ack_status')
->where('direction', 'outbound')
->where('created_at', '<', now()->subHours(4))
->count();
if ($unacknowledged > 0) {
alert("$unacknowledged documents without 997 acknowledgment!");
}
// Check partner communication
$silentPartners = EdiPartner::active()
->whereDoesntHave('documents', function ($q) {
$q->where('created_at', '>', now()->subDays(3));
})
->get();
foreach ($silentPartners as $partner) {
alert("No EDI communication from {$partner->name} in 3 days");
}
}
}Conclusion: EDI Isn't Dead, It's Essential
Every Fortune 500 company runs on EDI. Every major retailer, every logistics company, every manufacturer. Master EDI and you become invaluable to enterprise clients.
The system I've shown you processes millions of documents monthly with 99.9% accuracy. It's not sexy, but it moves billions in goods globally.
Start with one document type (204 or 850), one partner, and build from there. The patterns are the same whether you're processing one document or one million.
Getting Started with Laravel
New to Laravel? Before diving into enterprise EDI integrations, master the fundamentals with these hands-on tutorials:
- Build a Portfolio with Laravel - Learn Eloquent ORM, authentication, and file uploads
- Build a Blog with Laravel - Master routing, Blade templates, and database relationships
- Build E-Commerce with Laravel - Advanced patterns including payment integration and order management
Each tutorial includes AI-assisted prompts for every step, making it easy to build while learning Laravel's best practices.
Questions about specific EDI scenarios? Drop them below - I've probably encountered (and solved) them in production.
Fred
AUTHORFull-stack developer with 10+ years building production applications. I build and maintain production APIs that handle real traffic and real problems.

