为什么我的Laravel API的路由模型绑定参数在前端使用fetch请求时会为空,或者导致404/验证错误?

前端开发 2026-07-09

我正在做一个全栈应用,前端是一个使用JavaScript fetch 与Bootstrap 5的普通HTML页面,后端是一个Laravel 11的 API。我定义了一个标准的 Route::apiResource('invoice_details', InvoiceController::class); 路由,它处理发票行项的端点。用于获取数据的 GET 请求可以正常工作,然而我的 POSTPATCHDELETE 请求却出现了意料之外的行为。例如,在尝试一个 DELETE 请求时,控制器没有接收到模型,导致我实现的自定义 404 错误块,因为对象传入为空。此外,在进行一个 POST 请求时,InvoiceRequest 验证类被触发,但它返回错误响应,或在没有正确进入数据库的情况下悄悄失败。

看看我的Eloquent模型,Invoice 有一个名为 invoice_detaileshasMany 关系,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。

站内所有文章版权归属LeftHeroAI导航站,无授权禁止任何主体转载、抄袭、复制内容,亦不得私自架设镜像站点。一经侵权,本站将通过法律途径追责。

相关文章