为什么我的Laravel API的路由模型绑定参数在前端使用fetch请求时会为空,或者导致404/验证错误?
我正在做一个全栈应用,前端是一个使用JavaScript fetch 与Bootstrap 5的普通HTML页面,后端是一个Laravel 11的 API。我定义了一个标准的 Route::apiResource('invoice_details', InvoiceController::class); 路由,它处理发票行项的端点。用于获取数据的 GET 请求可以正常工作,然而我的 POST、PATCH 和 DELETE 请求却出现了意料之外的行为。例如,在尝试一个 DELETE 请求时,控制器没有接收到模型,导致我实现的自定义 404 错误块,因为对象传入为空。此外,在进行一个 POST 请求时,InvoiceRequest 验证类被触发,但它返回错误响应,或在没有正确进入数据库的情况下悄悄失败。
看看我的Eloquent模型,Invoice 有一个名为 invoice_detailes 的 hasMany 关系,InvoiceDetail 属于 Invoice。在我的 InvoiceController 中,方法的参数类型根据典型的资源控制器结构被标注为 InvoiceDetail $invoiceDetail。然而,由于资源路由命名为 invoice_details(复数且带下划线),我怀疑Laravel可能在隐式绑定时期望一个不同的变量名。此外,我的前端发送的JSON数据与数据库字段完全匹配,但在某些HTTP方法下验证无法正确处理。是什么原因导致我的JavaScript fetch请求的路由参数与Laravel的路由模型绑定之间的断连?我如何修正资源设置或控制器参数,以确保模型被正确解析并存储?
Teszt HTML:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.min.css">
<title>Document</title>
<style>
body {
max-width: 100vw;
}
@media (max-width: 992px) {
h3 {
font-size: 1.2rem !important;
}
p {
font-size: 0.9rem !important;
}
th,
td {
font-size: 0.8rem !important;
}
}
</style>
</head>
<body>
<nav>
<div class="nav nav-tabs" id="nav-tab" role="tablist">
<button class="nav-link active" id="nav-home-tab" data-bs-toggle="tab" data-bs-target="#nav-home"
type="button" role="tab" aria-controls="nav-home" aria-selected="true">Főoldal</button>
<button class="nav-link" id="nav-invoice-tab" data-bs-toggle="tab" data-bs-target="#nav-invoice"
type="button" role="tab" aria-controls="nav-invoice" aria-selected="false">Számlák</button>
<button class="nav-link" id="nav-new-tab" data-bs-toggle="tab" data-bs-target="#nav-new" type="button"
role="tab" aria-controls="nav-new" aria-selected="false">Új tétel</button>
</div>
</nav>
<div class="tab-content" id="nav-tabContent">
<div class="tab-pane fade show active p-3 px-5" id="nav-home" role="tabpanel" aria-labelledby="nav-home-tab">
<h1>Főoldal</h1>
</div>
<div class="tab-pane fade p-1 px-2 px-sm-3 py-sm-2 px-lg-5 py-lg-3" id="nav-invoice" role="tabpanel"
aria-labelledby="nav-invoice-tab">
<h1>Számlák</h1>
<button class="btn btn-outline-dark mb-5" id="getData">Adatok lekérése</button>
<div id="data"></div>
</div>
<div class="tab-pane fade p-3 px-5" id="nav-new" role="tabpanel" aria-labelledby="nav-new-tab">
<h1 class="h1 mb-5">Új hozzáadása</h1>
<div class="container">
<label class="form-label" for="invoice_id">Invoice Id:</label>
<input class="form-control" type="number" name="invoice_id" id="invoice_id">
<label class="form-label" for="product_name">Product Name</label>
<input class="form-control" type="text" name="product_name" id="product_name">
<label class="form-label" for="unit_price">Unit Price</label>
<input class="form-control" type="number" name="unit_price" id="unit_price">
<label class="form-label" for="quantity">Quantity</label>
<input class="form-control" type="number" name="quantity" id="quantity">
<label class="form-label" for="line_total">Line Total</label>
<input class="form-control" type="number" name="line_total" id="line_total">
<button class="btn btn-outline-dark mt-5" type="button" id="submit">Létrehozás</button>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"
integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
crossorigin="anonymous"></script>
<script>
document.getElementById("getData").addEventListener("click", async (e) => {
e.preventDefault();
try {
const dataDiv = document.getElementById("data");
dataDiv.innerHTML = "<div class=\"spinner-border\" role=\"status\"><span class=\"visually-hidden\">Loading...</span></div>";
const response = await fetch("http://localhost:8000/api/invoice_details", {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
});
if (!response.ok) {
console.log("Hiba a request során: ", response.status);
return;
}
const jsonData = await response.json();
let htmlContent = "";
jsonData.data.forEach(element => {
htmlContent += `<div class=\"row g-0\"><div class=\"col-12 col-md-4 col-sm-12\"><h3 class=\"responsive-h3\">${element.number}</h3><p>Created at: ${element.created_at}</p><p>Updated at: ${element.updated_at}</p></div>`;
htmlContent += "<div class=\"col-12 col-md-8 col-sm-12 overflow-auto\"><table class=\"table table-striped table-hover responsive-table\"><thead><tr><th>Id</th><th>Product Name</th><th>Unit Price</th><th>Quantity</th><th>Line Total</th><th>Created At</th><th>Updated At</th><th>Actions</th></tr></thead><tbody>";
element.invoice_detailes.forEach(detail => {
htmlContent += `<tr><th>${detail.id}</th><td>${detail.product_name}</td><td>${detail.unit_price}</td><td>${detail.quantity}</td><td>${detail.line_total}</td><td>${detail.created_at}</td><td>${detail.updated_at}</td><td><button id="${detail.id}" class="btn btn-outline-dark delete-btn"><i class="bi bi-trash3-fill"></i></button></td></tr>`;
});
htmlContent += "</tbody></table></div><hr class=\"hr my-4\"></div>";
});
dataDiv.innerHTML = htmlContent;
const delete_buttons = document.querySelectorAll(".delete-btn");
delete_buttons.forEach((button) => {
button.addEventListener("click", async (e) => {
if (confirm(`Biztosan törli a(z) ${button.id} id-jű elemet?`)) {
try {
const response_del = await fetch(`http://localhost:8000/api/invoice_details/${button.id}`, {
method: "DELETE",
headers: {
"Content-type": "application/json",
"Accept": "application/json"
},
});
if (!response_del.ok) {
console.log("Response failed: ", response_del.status);
return;
}
console.log(await response_del.json());
document.getElementById("getData").click();
}
catch (err) {
console.log("Something went wrong: ", err);
}
}
});
});
}
catch (err) {
console.log("Error: ", err)
}
});
document.getElementById("submit").addEventListener("click", async (e) => {
e.preventDefault();
let invoice_id = document.getElementById("invoice_id").value;
let product_name = document.getElementById("product_name").value;
let unit_price = document.getElementById("unit_price").value;
let quantity = document.getElementById("quantity").value;
let line_total = document.getElementById("line_total").value;
if (invoice_id == "" || product_name == "" || unit_price == "" || quantity == "" || line_total == "") {
alert("Nem adott meg minden adatot!");
return;
}
const inputJson = {
"invoice_id": invoice_id,
"product_name": product_name,
"unit_price": unit_price,
"quantity": quantity,
"line_total": line_total
};
try {
const response = await fetch("http://localhost:8000/api/invoice_details", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
},
body: JSON.stringify(inputJson),
});
if (!response.ok) {
console.log("A válasz sikertelen: ", response.status);
alert(response_json.message);
return;
}
const response_json = await response.text();
console.log(response_json);
alert("Sikeresen létrehozva!");
}
catch (err) {
console.log("An error occured: ", err);
}
});
</script>
</body>
</html>
Invoice Migration:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('invoices', function (Blueprint $table) {
$table->id();
$table->string('number');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
//
}
};
Invoice Detail migration:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('invoice_details', function (Blueprint $table) {
$table->id();
$table->foreignId('invoice_id')->constrained('invoices')->onDelete('cascade');
$table->string('product_name');
$table->integer('unit_price');
$table->integer('quantity');
$table->integer('line_total');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('invoice_details');
}
};
Invoice Seeder:
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class InvoiceSeeder extends Seeder
{
public function run(): void
{
DB::table('invoices')->insert([
['number' => 'SZLA-001/2026', 'created_at' => Carbon::now()->format('Y-m-d H:i:s'), 'updated_at' => Carbon::now()->format('Y-m-d H:i:s')],
['number' => 'SZLA-002/2026', 'created_at' => Carbon::now()->format('Y-m-d H:i:s'), 'updated_at' => Carbon::now()->format('Y-m-d H:i:s')],
['number' => 'SZLA-003/2026', 'created_at' => Carbon::now()->format('Y-m-d H:i:s'), 'updated_at' => Carbon::now()->format('Y-m-d H:i:s')],
['number' => 'SZLA-004/2026', 'created_at' => Carbon::now()->format('Y-m-d H:i:s'), 'updated_at' => Carbon::now()->format('Y-m-d H:i:s')],
]);
}
}
Database Seeder:
<?php
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
use WithoutModelEvents;
/**
* Seed the application's database.
*/
public function run(): void
{
// User::factory(10)->create();
User::factory()->create([
'name' => 'Test User',
'email' => '[email protected]',
]);
$this->call([
InvoiceSeeder::class,
InvoiceDetailsSeeder::class,
]);
}
}
Invoice Detail Seeder:
<?php
namespace Database\Seeders;
use App\Models\InvoiceDetail;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Carbon\Carbon;
use Illuminate\Support\Facades\Log;
class InvoiceDetailsSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$data = fopen(base_path("database/seeders/invoice_details.csv"), "r");
while (!feof($data)) {
$line = fgets($data);
$line_arr = explode(";", $line);
InvoiceDetail::create([
'id' => $line_arr[0],
'invoice_id' => (int)$line_arr[1],
'product_name' => $line_arr[2],
'unit_price' => (int)$line_arr[3],
'quantity' => (int)$line_arr[4],
'line_total' => (int)$line_arr[5],
'created_at' => Carbon::now()->format('Y-m-d H:i:s'),
'updated_at' => Carbon::now()->format('Y-m-d H:i:s')
]);
}
fclose($data);
}
}
Invoice Model:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Invoice extends Model
{
public $timestamps = true;
protected $fillable = [
'number'
];
public function invoice_detailes(){
return $this->hasMany(InvoiceDetail::class, 'invoice_id', 'id');
}
}
Invoice Detail Model:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class InvoiceDetail extends Model
{
public $timestamps = true;
protected $fillable = [
'id',
'invoice_id',
'product_name',
'unit_price',
'quantity',
'line_total'
];
public function invoice_detailes(){
return $this->belongsTo(Invoice::class, 'id', 'invoice_id');
}
}
Invoice Controller:
<?php
namespace App\Http\Controllers;
use App\Http\Requests\InvoiceRequest;
use App\Models\Invoice;
use App\Models\InvoiceDetail;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class InvoiceController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
$invoices = Invoice::with('invoice_detailes')->get();
return response()->json([
"data" => $invoices
], 200);
}
/**
* Store a newly created resource in storage.
*/
public function store(InvoiceRequest $request)
{
Log::error("eljutottam idaig\n\n\n\n\n");
$invoice = InvoiceDetail::create($request->validated());
return response()->json([
"message" => "Resource created successfully",
"invoice" => $invoice,
], 201);
}
/**
* Update the specified resource in storage.
*/
public function update(InvoiceRequest $request, InvoiceDetail $invoiceDetail)
{
if ($invoiceDetail == null || $invoiceDetail == []) return response()->json(["message" => "Nincs ilyen id-n adat!"], 404);
$invoiceDetail->update($request->validated());
return response()->json([
"Módosított adat" => $invoiceDetail,
], 200);
}
/**
* Remove the specified resource from storage.
*/
public function destroy(InvoiceDetail $invoiceDetail)
{
if ($invoiceDetail == null || $invoiceDetail == []) return response()->json(["message" => "Nincs ilyen id-n adat!"], 404);
$invoiceDetail->delete();
return response()->json([
"message" => "Adat sikeresen törölve!",
"id" => $invoiceDetail->id,
]);
}
}
Invoice Request:
<?php
namespace App\Http\Requests;
use Illuminate\Support\Facades\Log;
use Illuminate\Foundation\Http\FormRequest;
class InvoiceRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
if ($this->method() == "PATCH") {
return [
'invoice_id' => ["sometimes", "required", "integer", "exists:invoices,id"],
'product_name' => ["sometimes", "required", "string", "max:255"],
'unit_price' => ["sometimes", "required", "integer", "min:0"],
'quantity' => ["sometimes", "required", "integer", "min:0"],
'line_total' => ["sometimes", "required", "integer", "min:0"],
];
}
return [
'invoice_id' => ["required", "integer", "exists:invoices,id"],
'product_name' => ["required", "string", "max:255"],
'unit_price' => ["required", "integer", "min:0"],
'quantity' => ["required", "integer", "min:0"],
'line_total' => ["required", "integer", "min:0"],
];
}
public function messages(): array
{
return [
"invoice_id.exists" => "Nincs a megadott id-vel található invoice az adatbázisban!",
"invoice_id.integer" => "Az invoice_id nem szám!",
];
}
}
API.php:
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\InvoiceController;
Route::get('/user', function (Request $request) {
return $request->user();
})->middleware('auth:sanctum');
Route::apiResource('invoice_details', InvoiceController::class);
解决方案
根本原因
核心问题正是你所怀疑的:隐式路由模型绑定失败,因为控制器的变量名与Laravel生成的路由参数名不匹配。Route::apiResource('invoice_details', InvoiceController::class) 生成的路由中,参数名为 invoice_detail。请注意,它会将变量名进行单数化并改为蛇形命名(参见 php artisan route:list)。
在运行时,Laravel会将路由参数名与控制器方法的变量名进行匹配(区分大小写)。就像你的代码中写的:
phppublic function destroy(InvoiceDetail $invoiceDetail)
路由参数是 invoice_detail(而不是 invoiceDetail),这些名称不匹配,Laravel将回退到普通的服务容器解析,对于一个Eloquent模型类来说,这就只会给你一个新的、空的 InvoiceDetail 实例。
如何修复?
你有两种办法。最简单的是把控制器变量名改为与参数名一致,但那样看起来很难看。或者(我会选后者)你可以对路由参数进行别名/重命名:
Route::apiResource('invoice_details', InvoiceController::class)
->parameters(['invoice_details' => 'invoiceDetail']);
这会告诉Laravel去查找名为 $invoiceDetails 的参数,并把来自 invoice_details 的值传给它。
附注:你不需要 null 的检查。如果ID与任何现有实体不匹配,Laravel会抛出404。