Skip to content

Conversation

@yousefkadah
Copy link

Native Composite Primary Key Support for Eloquent ORM

Summary

This PR implements comprehensive support for composite (multi-column) primary keys in Laravel's Eloquent ORM, enabling developers to work seamlessly with tables that use multiple columns as their primary key.

Motivation

Many enterprise and normalized database schemas use composite primary keys for:

  • Junction tables (e.g., order_items with order_id + product_id)
  • Multi-tenant systems (e.g., tenant_id + id)
  • Versioned records (e.g., document_id + version)
  • Legacy database integration where composite keys are already established

Currently, developers must use workarounds or third-party packages like awobaz/compoships, which only support relationships but not full CRUD operations.

Changes

Core Model Changes (Illuminate\Database\Eloquent\Model)

1. Enhanced Property Types

// Before: string only
protected $primaryKey = 'id';

// After: string or array
protected $primaryKey = 'id'; // or ['order_id', 'product_id']
protected $keyType = 'int';    // or ['int', 'int']
2. New Methods
hasCompositeKey() - Detects if model uses composite keys
3. Enhanced Methods
getKey() - Returns array for composite keys: ['order_id' => 1, 'product_id' => 5]
getKeyName() - Returns array of key names for composite keys
getQualifiedKeyName() - Returns array of qualified key names
setKeysForSaveQuery() - Applies multiple WHERE clauses for updates/deletes
getKeyForSaveQuery() - Accepts optional key parameter
performInsert() - Validates composite keys cannot be auto-incrementing
Builder Changes (Illuminate\Database\Eloquent\Builder)
1. Enhanced Methods
whereKey() - Detects composite keys and delegates to specialized handler
2. New Methods
whereCompositeKey() - Handles composite key WHERE clauses
Single key: ['order_id' => 1, 'product_id' => 5]
Multiple keys: [['order_id' => 1, 'product_id' => 5], ['order_id' => 2, 'product_id' => 3]]
isAssociativeArray() - Helper to distinguish single vs multiple composite keys
Usage
Model Definition
use Illuminate\Database\Eloquent\Model;

class OrderItem extends Model
{
    // Define composite primary key
    protected $primaryKey = ['order_id', 'product_id'];
    
    // Composite keys cannot auto-increment
    public $incrementing = false;
    
    // Optional: specify types for each key
    protected $keyType = ['int', 'int']; // or just 'int' for all
}
Migration (Already Supported!)
Schema::create('order_items', function (Blueprint $table) {
    $table->unsignedBigInteger('order_id');
    $table->unsignedBigInteger('product_id');
    $table->integer('quantity');
    $table->timestamps();
    
    // Composite primary key (Blueprint already supports this)
    $table->primary(['order_id', 'product_id']);
    
    $table->foreign('order_id')->references('id')->on('orders');
    $table->foreign('product_id')->references('id')->on('products');
});
CRUD Operations
// CREATE
$item = OrderItem::create([
    'order_id' => 1,
    'product_id' => 5,
    'quantity' => 10
]);

// READ - Find by composite key
$item = OrderItem::find(['order_id' => 1, 'product_id' => 5]);

$item = OrderItem::findOrFail(['order_id' => 1, 'product_id' => 5]);

$items = OrderItem::findMany([
    ['order_id' => 1, 'product_id' => 5],
    ['order_id' => 2, 'product_id' => 3]
]);

// UPDATE
$item = OrderItem::find(['order_id' => 1, 'product_id' => 5]);
$item->quantity = 20;
$item->save(); // Executes: UPDATE ... WHERE order_id = 1 AND product_id = 5

// DELETE
$item->delete(); // Executes: DELETE ... WHERE order_id = 1 AND product_id = 5
Query Builder
// Single composite key lookup
$item = OrderItem::whereKey(['order_id' => 1, 'product_id' => 5])->first();

// Multiple composite keys (whereIn equivalent)
$items = OrderItem::whereKey([
    ['order_id' => 1, 'product_id' => 5],
    ['order_id' => 2, 'product_id' => 3]
])->get();

// Exclusion
$items = OrderItem::whereKeyNot(['order_id' => 1, 'product_id' => 5])->get();

// Get key values
$keys = $item->getKey(); 
// Returns: ['order_id' => 1, 'product_id' => 5]
Technical Implementation
Detection Strategy
Composite keys are detected using a simple type check:
public function hasCompositeKey()
{
    return is_array($this->primaryKey);
}
This provides zero overhead for existing single-key models.
Query Building
For composite keys, WHERE clauses are constructed using multiple conditions:
// Single composite key
WHERE order_id = 1 AND product_id = 5

// Multiple composite keys
WHERE (order_id = 1 AND product_id = 5) 
   OR (order_id = 2 AND product_id = 3)

yousefkadah and others added 3 commits October 21, 2025 18:20
This commit adds the ConfirmableTrait to config:cache, route:cache,
view:cache, and event:cache commands to prevent accidental cache
regeneration in production environments.

Changes:
- Added ConfirmableTrait to all cache commands
- Added --force option to bypass confirmation prompts
- Converted $name property to $signature to support options
- Added confirmToProceed() check before cache operations

The confirmation prompt only appears when running in production
environment, following the same pattern used in other destructive
commands like key:generate.

Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
This commit adds missing @throws annotations to methods that throw
exceptions but were not properly documented. This improves:
- IDE autocomplete and warnings
- PHPStan/Psalm static analysis
- Developer experience

Changes:
- Config/Repository: Added @throws InvalidArgumentException to string(), integer(), float(), boolean(), and array() methods
- Redis/Connections/PacksPhpRedisValues: Added @throws RuntimeException and UnexpectedValueException to pack() method
- Session/SymfonySessionDecorator: Added @throws BadMethodCallException to registerBag(), getBag(), and getMetadataBag() methods

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
This commit implements comprehensive support for composite (multi-column)
primary keys in Laravel's Eloquent ORM, enabling developers to work with
tables that use multiple columns as their primary key.

## Core Changes

### Model Class (src/Illuminate/Database/Eloquent/Model.php)
- Updated `$primaryKey` property to accept `string|array<int, string>`
- Updated `$keyType` property to accept `string|array<int, string>`
- Added `hasCompositeKey()` method to detect composite keys
- Modified `getKey()` to return array of key values for composite keys
- Modified `getKeyName()` to return array of key names for composite keys
- Modified `getQualifiedKeyName()` to return array of qualified key names
- Modified `setKeysForSaveQuery()` to handle multiple WHERE clauses
- Modified `getKeyForSaveQuery()` to accept optional key parameter
- Added validation in `performInsert()` to prevent auto-incrementing composite keys

### Builder Class (src/Illuminate/Database/Eloquent/Builder.php)
- Modified `whereKey()` to detect and handle composite keys
- Added `whereCompositeKey()` method for composite key WHERE clauses
- Added `isAssociativeArray()` helper method
- Added `InvalidArgumentException` import for validation errors

## Features

✅ **Create**: Insert records with composite primary keys
✅ **Read**: Find records using composite key arrays
✅ **Update**: Update records identified by composite keys
✅ **Delete**: Delete records using composite keys
✅ **Query Builder**: whereKey() and whereKeyNot() support
✅ **Validation**: Prevents auto-incrementing composite keys
✅ **Backward Compatible**: Zero impact on existing single-key models

## Usage Example

```php
class OrderItem extends Model {
    protected $primaryKey = ['order_id', 'product_id'];
    public $incrementing = false;
}

// Find by composite key
$item = OrderItem::find(['order_id' => 1, 'product_id' => 5]);

// Update
$item->quantity = 10;
$item->save();

// Query builder
OrderItem::whereKey(['order_id' => 1, 'product_id' => 5])->first();
```

## Tests

Added comprehensive integration test suite (18 test cases):
- Basic CRUD operations
- findOrFail() with composite keys
- whereKey() and whereKeyNot() queries
- Validation for auto-incrementing restriction
- fresh() and refresh() methods
- Multiple record queries

## Breaking Changes

None - fully backward compatible with existing single-key models.

Related to enterprise requirements for normalized database schemas
commonly using composite primary keys.
Copy link
Contributor

@shaedrich shaedrich left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your PR includes changes form #57475 and #57476—I assume, that happened by mistake

Could you please make sure to only include one set of changes per PR?

$keyNames = $this->model->getKeyName();

// Single composite key: ['order_id' => 1, 'product_id' => 5]
if ($this->isAssociativeArray($keyValues)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could just be:

Suggested change
if ($this->isAssociativeArray($keyValues)) {
if (! array_is_list($keyValues))) {

{
if ($this->hasCompositeKey()) {
return array_map(
fn ($key) => $this->qualifyColumn($key),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you want, you can use first-class callable syntax here to improve static analysis among other things

Suggested change
fn ($key) => $this->qualifyColumn($key),
$this->qualifyColumn(...),

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants