carlin/laravel-api-relations

Eloquent-like API relationships for Laravel with composite key support and N+1 prevention

Installs: 1

Dependents: 0

Suggesters: 0

Security: 0

Stars: 1

Watchers: 0

Forks: 0

Open Issues: 0

pkg:composer/carlin/laravel-api-relations

v1.1.1 2025-12-15 06:37 UTC

This package is auto-updated.

Last update: 2025-12-15 06:38:17 UTC


README

Latest Version License PHP Version

Eloquent-like API relationships for Laravel with composite key support and N+1 prevention through intelligent batch loading.

English | įŽ€äŊ“中文

Features

  • 🚀 Eloquent-like syntax - Define API relationships just like database relationships
  • 🔑 Composite key support - Handle complex relationships with multiple keys
  • ⚡ N+1 prevention - Automatic batch loading for optimal performance
  • đŸŽ¯ Lazy & Eager loading - Full support for both loading strategies
  • 🔤 Case-insensitive matching - Optional case-insensitive key matching for flexible API integration

Requirements

  • PHP 8.1 or higher
  • Laravel 8.x, 9.x, or 10.x

Installation

Install the package via Composer:

composer require carlin/laravel-api-relations

Why This Package?

Before: The Traditional Approach ❌

Without this package, you typically need Service classes to fetch and attach API data:

// UserService.php
class UserService
{
    public function getUserWithProfile($userId)
    {
        $user = User::find($userId);
        
        // Fetch profile from external API
        $response = Http::post('https://api.example.com/profiles', [
            'user_ids' => [$userId]
        ]);
        $profiles = $response->json();
        $user->profile = $profiles[0] ?? null;
        
        return $user;
    }
    
    public function getUsersWithProfiles($userIds)
    {
        $users = User::whereIn('id', $userIds)->get();
        
        // Batch fetch to avoid N+1
        $response = Http::post('https://api.example.com/profiles', [
            'user_ids' => $userIds
        ]);
        $profiles = collect($response->json())->keyBy('user_id');
        
        // Manually attach profiles to users
        foreach ($users as $user) {
            $user->profile = $profiles->get($user->id);
        }
        
        return $users;
    }
}

// Controller usage
class UserController extends Controller
{
    public function index(UserService $userService)
    {
        // Must remember to use the service method
        $users = $userService->getUsersWithProfiles([1, 2, 3]);
        
        return view('users.index', compact('users'));
    }
    
    public function show($id, UserService $userService)
    {
        // Different method for single user
        $user = $userService->getUserWithProfile($id);
        
        return view('users.show', compact('user'));
    }
}

Problems:

  • 🔴 Need separate Service classes for API data fetching
  • 🔴 Controllers must remember to use specific service methods
  • 🔴 Different methods for single vs. multiple records
  • 🔴 Manual data attachment in every service method
  • 🔴 Easy to forget batch loading, causing N+1 problems
  • 🔴 Can't use Eloquent's with() for eager loading
  • 🔴 Breaking Eloquent conventions and patterns

After: With Laravel API Relations ✅

API relationships work exactly like Eloquent relationships:

// User.php
class User extends Model
{
    use HasApiRelations;
    
    public function profile()
    {
        return $this->hasOneApi(
            callback: fn($userIds) => Http::post('https://api.example.com/profiles', [
                'user_ids' => $userIds
            ])->json(),
            foreignKey: 'user_id',
            localKey: 'id'
        );
    }
    
    public function posts()
    {
        return $this->hasManyApi(
            callback: fn($userIds) => Http::post('https://api.example.com/posts', [
                'user_ids' => $userIds
            ])->json(),
            foreignKey: 'user_id',
            localKey: 'id'
        );
    }
}

// Controller usage - just like regular Eloquent!
class UserController extends Controller
{
    public function index()
    {
        // Automatic batch loading - single API call for all users
        $users = User::with('profile', 'posts')->get();
        
        return view('users.index', compact('users'));
    }
    
    public function show($id)
    {
        // Lazy loading - works seamlessly
        $user = User::find($id);
        $profile = $user->profile()->getResults();
        
        return view('users.show', compact('user', 'profile'));
    }
}

Benefits:

  • ✅ No service layer needed for API relationships
  • ✅ Use standard Eloquent with() for eager loading
  • ✅ Automatic N+1 prevention through intelligent batching
  • ✅ Consistent API with database relationships
  • ✅ Single model definition works for all scenarios
  • ✅ Controllers stay clean and follow Laravel conventions
  • ✅ Built-in composite key support

Quick Start

1. Add the Trait to Your Model

use Carlin\LaravelApiRelations\Traits\HasApiRelations;

class User extends Model
{
    use HasApiRelations;
    
    // Define a has-one API relationship
    public function profile()
    {
        return $this->hasOneApi(
            callback: fn($userIds) => $this->fetchProfilesFromApi($userIds),
            foreignKey: 'user_id',
            localKey: 'id'
        );
    }
    
    // Define a has-many API relationship
    public function posts()
    {
        return $this->hasManyApi(
            callback: fn($userIds) => $this->fetchPostsFromApi($userIds),
            foreignKey: 'user_id',
            localKey: 'id'
        );
    }
    
    private function fetchProfilesFromApi(array $userIds): array
    {
        // Call your external API
        $response = Http::post('https://api.example.com/profiles', [
            'user_ids' => $userIds
        ]);
        
        return $response->json();
    }
    
    private function fetchPostsFromApi(array $userIds): array
    {
        // Call your external API
        $response = Http::post('https://api.example.com/posts', [
            'user_ids' => $userIds
        ]);
        
        return $response->json();
    }
}

2. Use Like Regular Eloquent Relationships

// Lazy loading (single API call per model)
$user = User::find(1);
$profile = $user->profile()->getResults();
$posts = $user->posts()->getResults();

// Eager loading (single batched API call for all models)
$users = User::with('profile', 'posts')->get();

foreach ($users as $user) {
    echo $user->profile['name'];
    foreach ($user->posts as $post) {
        echo $post['title'];
    }
}

Advanced Usage

Case-Insensitive Key Matching

By default, key matching is case-sensitive. You can enable case-insensitive matching for scenarios where API keys might have inconsistent casing:

class User extends Model
{
    use HasApiRelations;
    
    public function profile()
    {
        return $this->hasOneApi(
            callback: fn($userIds) => Http::post('https://api.example.com/profiles', [
                'user_ids' => $userIds
            ])->json(),
            foreignKey: 'user_id',
            localKey: 'id',
            caseInsensitive: true  // Enable case-insensitive matching
        );
    }
}

// Example: Model has user_code = 'ABC'
// API returns data with user_code = 'abc' or 'Abc' or 'ABC'
// All variations will match successfully

When to use case-insensitive matching:

  • External APIs return inconsistent key casing
  • Legacy systems with mixed-case identifiers
  • Case-insensitive database collations
  • Multi-source data integration

Composite Keys

Handle relationships with multiple key fields:

class Order extends Model
{
    use HasApiRelations;
    
    public function orderDetails()
    {
        return $this->hasOneApi(
            callback: fn($keys) => $this->fetchOrderDetails($keys),
            foreignKey: ['customer_id', 'order_number'],
            localKey: ['customer_id', 'order_number']
        );
    }
    
    private function fetchOrderDetails(array $compositeKeys): array
    {
        // $compositeKeys = [
        //     ['customer_id' => 1, 'order_number' => 'ORD-001'],
        //     ['customer_id' => 2, 'order_number' => 'ORD-002'],
        // ]
        
        $response = Http::post('https://api.example.com/order-details', [
            'keys' => $compositeKeys
        ]);
        
        return $response->json();
    }
}

// Usage
$order = Order::find(1);
$details = $order->orderDetails()->getResults();

API Callback Format

Your API callback receives an array of keys and should return an array of results:

For hasOneApi:

// Input: [1, 2, 3]
// Output: [
//     ['user_id' => 1, 'name' => 'John', 'email' => 'john@example.com'],
//     ['user_id' => 2, 'name' => 'Jane', 'email' => 'jane@example.com'],
//     ['user_id' => 3, 'name' => 'Bob', 'email' => 'bob@example.com'],
// ]

For hasManyApi:

// Input: [1, 2, 3]
// Output: [
//     ['user_id' => 1, 'title' => 'Post 1'],
//     ['user_id' => 1, 'title' => 'Post 2'],
//     ['user_id' => 2, 'title' => 'Post 3'],
//     ['user_id' => 3, 'title' => 'Post 4'],
//     ['user_id' => 3, 'title' => 'Post 5'],
// ]

N+1 Prevention Example

// ❌ BAD: N+1 problem (100 users = 100 API calls)
$users = User::all();
foreach ($users as $user) {
    $profile = $user->profile()->getResults(); // API call per user
}

// ✅ GOOD: Batch loading (100 users = 1 API call)
$users = User::with('profile')->get();
foreach ($users as $user) {
    $profile = $user->profile; // No additional API calls
}

How It Works

  1. Lazy Loading: When you access a relationship on a single model, it calls the API with that model's key
  2. Eager Loading: When you use with(), it collects all keys from all models and makes a single batched API call
  3. Composite Keys: Multiple fields are combined into an associative array and properly matched
  4. Result Matching: API results are automatically matched back to the correct models using the foreign key

API Reference

hasOneApi

Define a has-one API relationship.

public function hasOneApi(
    callable $apiCallback,
    string|array $foreignKey,
    string|array $localKey = 'id',
    bool $caseInsensitive = false
): HasOneApi

Parameters:

  • $apiCallback - Function that receives array of keys and returns API results
  • $foreignKey - Field name(s) in the API response to match against
  • $localKey - Field name(s) in the local model (defaults to 'id')
  • $caseInsensitive - Enable case-insensitive key matching (defaults to false)

Returns: null or array when no match found

hasManyApi

Define a has-many API relationship.

public function hasManyApi(
    callable $apiCallback,
    string|array $foreignKey,
    string|array $localKey = 'id',
    bool $caseInsensitive = false
): HasManyApi

Parameters:

  • $apiCallback - Function that receives array of keys and returns API results
  • $foreignKey - Field name(s) in the API response to match against
  • $localKey - Field name(s) in the local model (defaults to 'id')
  • $caseInsensitive - Enable case-insensitive key matching (defaults to false)

Returns: Empty array [] when no matches found

Use Cases

Perfect for scenarios where you need to:

  • Fetch user profiles from a separate authentication service
  • Load product details from an external catalog API
  • Retrieve order information from a third-party system
  • Access microservice data while maintaining Eloquent-like syntax
  • Handle multi-tenant relationships with composite keys

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

License

This package is open-sourced software licensed under the MIT license.

Credits

Support

If you discover any issues, please email rjwangnixingfu@gmail.com or create an issue on GitHub.