diff --git a/app/Http/Controllers/Assets/AssetsController.php b/app/Http/Controllers/Assets/AssetsController.php
index 6e6e8766e1..1c71ed83ae 100755
--- a/app/Http/Controllers/Assets/AssetsController.php
+++ b/app/Http/Controllers/Assets/AssetsController.php
@@ -444,6 +444,11 @@ class AssetsController extends Controller
$asset->expected_checkin = $request->input('expected_checkin', null);
$asset->requestable = $request->input('requestable', 0);
$asset->rtd_location_id = $request->input('rtd_location_id', null);
+ // Current location is editable from the asset edit form as of
+ // the location-dropdown addition. Blank clears it (the mutator
+ // normalizes '' / 0 to NULL) so the next checkout can re-derive
+ // location_id from the target. See general.location_edit_help.
+ $asset->location_id = $request->input('location_id', null);
$asset->byod = $request->input('byod', 0);
$status = Statuslabel::find($request->input('status_id'));
diff --git a/app/Models/Asset.php b/app/Models/Asset.php
index 7998362412..14b76c713d 100644
--- a/app/Models/Asset.php
+++ b/app/Models/Asset.php
@@ -108,6 +108,40 @@ class Asset extends Depreciable
'deleted_at' => 'datetime',
];
+ /**
+ * location_id and company_id should store NULL when there's no
+ * assignment, never 0. Old data and previous bugs occasionally
+ * left `0` behind (empty select2 → '' → integer-cast → 0), which
+ * then breaks `exists:` validation and FMCS queries that treat
+ * NULL and 0 as different. Mutators normalize on write; matching
+ * accessors normalize on read so legacy rows already storing 0
+ * present as null at the model boundary until they're re-saved.
+ * Mirrors the parent_id pattern on Company / SnipeModel.
+ */
+ public function setLocationIdAttribute($value): void
+ {
+ $this->attributes['location_id'] = ($value === '' || $value === null || (int) $value === 0)
+ ? null
+ : (int) $value;
+ }
+
+ public function getLocationIdAttribute($value): ?int
+ {
+ return ($value === null || (int) $value === 0) ? null : (int) $value;
+ }
+
+ public function setCompanyIdAttribute($value): void
+ {
+ $this->attributes['company_id'] = ($value === '' || $value === null || (int) $value === 0)
+ ? null
+ : (int) $value;
+ }
+
+ public function getCompanyIdAttribute($value): ?int
+ {
+ return ($value === null || (int) $value === 0) ? null : (int) $value;
+ }
+
protected $rules = [
'model_id' => ['required', 'integer', 'exists:models,id,deleted_at,NULL', 'not_array'],
'status_id' => ['required', 'integer', 'exists:status_labels,id'],
diff --git a/resources/lang/en-US/general.php b/resources/lang/en-US/general.php
index f9e63d659e..a71d89872a 100644
--- a/resources/lang/en-US/general.php
+++ b/resources/lang/en-US/general.php
@@ -644,6 +644,7 @@ return [
'copied' => 'Copied!',
'status_compatibility' => 'If assets are already assigned, they cannot be changed to a non-deployable status type and this value change will be skipped.',
'rtd_location_help' => 'This is the location of the asset when it is not checked out',
+ 'location_edit_help' => 'If left blank, the location will be updated to match the location of whoever (or wherever) this asset is checked out to.',
'item_not_found' => ':item_type ID :id does not exist or has been deleted',
'item_target_not_found_hard' => ':item_type ID :id does not exist or has been hard-deleted. Would you like to force a checkin?',
'force_checkin' => 'Force Checkin',
diff --git a/resources/views/hardware/edit.blade.php b/resources/views/hardware/edit.blade.php
index 75d06483c4..b889580c26 100755
--- a/resources/views/hardware/edit.blade.php
+++ b/resources/views/hardware/edit.blade.php
@@ -118,6 +118,17 @@
input_div_class="col-md-7 col-sm-12"
/>
+ {{-- Current location. When blank, the observer / checkout flow
+ updates location_id to match the checkout target's location
+ (user's location, target-location, or parent asset's location).
+ See the note under general.location_edit_help. --}}
+
+
{{-- Default (ready-to-deploy) location --}}
company_id === null`.
+ *
+ * The mutator normalizes on write (Asset::setLocationIdAttribute /
+ * setCompanyIdAttribute) and the accessor normalizes on read for
+ * legacy rows already storing 0.
+ */
+class AssetZeroIdNormalizationTest extends TestCase
+{
+ public function test_setting_location_id_to_zero_normalizes_to_null(): void
+ {
+ $asset = Asset::factory()->create();
+
+ $asset->location_id = 0;
+ $this->assertNull($asset->location_id);
+
+ $asset->location_id = '0';
+ $this->assertNull($asset->location_id);
+
+ $asset->location_id = '';
+ $this->assertNull($asset->location_id);
+ }
+
+ public function test_setting_company_id_to_zero_normalizes_to_null(): void
+ {
+ $asset = Asset::factory()->create();
+
+ $asset->company_id = 0;
+ $this->assertNull($asset->company_id);
+
+ $asset->company_id = '0';
+ $this->assertNull($asset->company_id);
+
+ $asset->company_id = '';
+ $this->assertNull($asset->company_id);
+ }
+
+ public function test_legacy_zero_stored_in_db_reads_as_null(): void
+ {
+ // Simulate a row from the pre-mutator era where 0 slipped in.
+ // DB::update bypasses model events so we can write the invalid
+ // value directly.
+ $asset = Asset::factory()->create();
+ DB::table('assets')->where('id', $asset->id)->update([
+ 'location_id' => 0,
+ 'company_id' => 0,
+ ]);
+
+ $reloaded = Asset::find($asset->id);
+
+ $this->assertNull($reloaded->location_id);
+ $this->assertNull($reloaded->company_id);
+ }
+
+ public function test_real_ids_still_persist_and_read_normally(): void
+ {
+ // Non-regression: normalization must not touch valid ids.
+ $company = Company::factory()->create();
+ $location = Location::factory()->create();
+ $asset = Asset::factory()->create();
+
+ $asset->company_id = $company->id;
+ $asset->location_id = $location->id;
+ $asset->save();
+
+ $reloaded = Asset::find($asset->id);
+
+ $this->assertSame($company->id, $reloaded->company_id);
+ $this->assertSame($location->id, $reloaded->location_id);
+ }
+}