Form field labels request
August 19, 2025 · AI Conversations
give all form field label
To get all the **form field labels**, can you do one of these:
– Share a **screenshot** of the form screen, or
– Copy/paste the form fields you see into chat.
Then I’ll list out all the field labels cleanly for you.
Do you want to upload a screenshot of that Marvel screen?
1. **Wohnfläche*** (m²)
2. **Zimmer***
3. **Kaltmiete*** (Euro)
4. **Verfügbar Ab** (Datum)
5. **Nebenkosten** (Euro)
6. **Kaution** (Euro)
7. **Heizunkosten in Nebenkosten**
8. **Gesamtmiete*** (Euro)
Would you like me to also translate these labels into English for easier reference?
### **Details**
1. Einbauküche
2. Balkon/Terasse
3. Gäste WC
4. WG geeignet
5. Aufzug
6. Garten
7. Keller
8. Stufenloser Zugang
9. Wohnberechtigungsschein
10. Haustiere erlaubt
### **Qualität der Wohnung**
11. Neubau
12. Einfach
13. Gehobene
14. Normal
15. Luxus
Do you also want me to prepare the **combined list** of all form labels from both screens together?
### **Details**
1. Anzahl voorzieningen
2. Schlafzimmer
3. Badezimmer
4. Etage (Von …)
5. Nutzfläche (m²)
### **Wohnberechtigungsschein**
6. Erforderlich
7. Nichterforderlich
### **Heizungsart**
8. Etagenheizung
9. Zentralheizung
—
👉 Do you want me to now create a **full combined list** of all fields from **all three screens together** in one clean list?
<?php namespace AppModel;
use SilverStripeORMDataObject;
class ApartmentDetails extends DataObject
{
private static $table_name = 'ApartmentDetail';
private static $db = [
// Screen 1 (already present)
'Wohnflache' => 'Varchar(15)', // e.g. 82.50
'Zimmer' => 'Varchar(50)', // e.g. 3
'Kaltmiete' => 'Varchar(50)',
'FloorVerfugbarAb' => 'Varchar(50)', // e.g. 2nd / EG
'Nebenkosten' => 'Varchar(50)',
'Kaution' => 'Varchar(50)',
'HeizunkostenInNebenkosten' => 'Varchar(50)',
'Gesamtmiete' => 'Varchar(50)',
'QualitatDerWohnung' => 'Varchar(50)', // e.g. Neubau/Einfach/Gehoben/Normal/Luxus
// Screen 3 (NEW)
'AnzahlVoorzieningen' => 'Int', // count of amenities
'Schlafzimmer' => 'Int',
'Badezimmer' => 'Int',
'EtageVon' => 'Varchar(10)', // e.g. 0, 1, EG
'EtageBis' => 'Varchar(10)', // optional range end
'Nutzflache' => 'Varchar(15)', // m² value
// Wohnberechtigungsschein
'WohnberechtigungsscheinErforderlich' => 'Boolean',
// Heizungsart
'Etagenheizung' => 'Boolean',
'Zentralheizung' => 'Boolean',
];
// Inverse of Apartment.Has one Details
private static $belongs_to = [
'Apartment' => Apartment::class . '.Detail',
];
private static $summary_fields = [
'Wohnflache' => 'Wohnflache',
'Zimmer' => 'Zimmer',
'Kaltmiete' => 'Kaltmiete',
'FloorVerfugbarAb' => 'FloorVerfugbarAb',
'Nebenkosten' => 'Nebenkosten',
'Kaution' => 'Kaution',
'HeizunkostenInNebenkosten' => 'HeizunkostenInNebenkosten',
'Gesamtmiete' => 'Gesamtmiete',
'QualitatDerWohnung' => 'QualitatDerWohnung',
// NEW summary fields
'AnzahlVoorzieningen' => 'AnzahlVoorzieningen',
'Schlafzimmer' => 'Schlafzimmer',
'Badezimmer' => 'Badezimmer',
'EtageVon' => 'EtageVon',
'EtageBis' => 'EtageBis',
'Nutzflache' => 'Nutzflache',
'WohnberechtigungsscheinErforderlich' => 'WBS Erforderlich',
'Etagenheizung' => 'Etagenheizung',
'Zentralheizung' => 'Zentralheizung',
];
}
Notes:
– I added `EtageVon` **and** `EtageBis` to support a floor range since the UI shows “Etage … Von”. If you only need one value, remove `EtageBis`.
– `Nutzflache` mirrors your `Wohnflache` naming (no umlauts) and is stored as text; switch to `Decimal(10,2)` if you prefer numeric validation.
– Binary options (WBS required, Etagen-/Zentralheizung) are Booleans. If you want mutually exclusive selection, you could instead use an `Enum` for heating type.
Want me to also add the **screen 2** toggle fields (Einbauküche, Balkon/Terrasse, etc.) to this model?
Here are the new fields from this screen:
### **Details**
– Internetgeschwindigkeit:
– Alle Geschwindigkeit
– Mind. 100 Mbit/s
– Mind. 250 Mbit/s
– Mind. 1000 Mbit/s
– Energieeffizienzklasse:
– A
– B
– C
– D+
—
Here’s how you can extend your **`ApartmentDetails`** model to include these:
<?php namespace AppModel;
use SilverStripeORMDataObject;
class ApartmentDetails extends DataObject
{
private static $table_name = 'ApartmentDetail';
private static $db = [
// Previous fields...
// Internet speed (store chosen option as enum)
'Internetgeschwindigkeit' => "Enum('Alle,100,250,1000','Alle')",
// Energy efficiency class
'Energieeffizienzklasse' => "Enum('A,B,C,D+','A')",
];
private static $belongs_to = [
'Apartment' => Apartment::class . '.Detail',
];
private static $summary_fields = [
// Previous fields...
'Internetgeschwindigkeit' => 'Internetgeschwindigkeit',
'Energieeffizienzklasse' => 'Energieeffizienzklasse',
];
}
✅ This way you keep the radio-style options as `Enum` values (so they can only be one of the defined choices).
Do you want me to now prepare the **full final model** including **all fields from every screen (1–4)** in one clean version?
### **General Apartment Information**
– **Objektnummer**
– **Gruppennummer**
– **Wohnung Type*** (options: Wohnung, WG Zimmer, Haus, Wohnung auf Zeit)
– **Garage oder Stellplatz**
—
Since these are *general identifiers* and not detailed attributes, they actually belong better in your **`Apartment`** model (not `ApartmentDetails`).
✅ Suggestion:
– Keep **Objektnummer, Gruppennummer, WohnungType, GarageOderStellplatz** inside `Apartment`.
– Keep **all detailed fields (rent, rooms, amenities, heating, internet, energy, etc.)** inside `ApartmentDetails`.
Would you like me to now create a **final combined version of both models (`Apartment` + `ApartmentDetails`)** with all fields from **all 5 screens** structured cleanly?
You now have **two models**:
1. **`Apartment`** → stores general identifiers + relation to details.
2. **`ApartmentDetails`** → stores all the actual property details from the form screens.
I’ll also show you how to **output the page heading** in your SilverStripe template.
—
### 📌 `Apartment.php`
<?php namespace AppModel;
use SilverStripeORMDataObject;
class Apartment extends DataObject
{
private static $table_name = 'Apartment';
private static $db = [
'Objektnummer' => 'Varchar(50)',
'Gruppennummer' => 'Varchar(50)',
'WohnungType' => "Enum('Wohnung,WG Zimmer,Haus,Wohnung auf Zeit','Wohnung')",
'GarageOderStellplatz'=> 'Boolean'
];
private static $has_one = [
'Detail' => ApartmentDetails::class
];
private static $summary_fields = [
'Objektnummer' => 'Objektnummer',
'Gruppennummer' => 'Gruppennummer',
'WohnungType' => 'Wohnung Type',
'GarageOderStellplatz.Nice' => 'Garage/Stellplatz'
];
}
—
### 📌 `ApartmentDetails.php`
<?php namespace AppModel;
use SilverStripeORMDataObject;
class ApartmentDetails extends DataObject
{
private static $table_name = 'ApartmentDetail';
private static $db = [
// Screen 1
'Wohnflache' => 'Decimal(10,2)',
'Zimmer' => 'Int',
'Kaltmiete' => 'Varchar(50)',
'FloorVerfugbarAb' => 'Varchar(50)',
'Nebenkosten' => 'Varchar(50)',
'Kaution' => 'Varchar(50)',
'HeizunkostenInNebenkosten' => 'Varchar(50)',
'Gesamtmiete' => 'Varchar(50)',
'QualitatDerWohnung' => "Enum('Neubau,Einfach,Gehoben,Normal,Luxus','Normal')",
// Screen 2 (Amenities)
'Einbaukuche' => 'Boolean',
'BalkonTerasse' => 'Boolean',
'GasteWC' => 'Boolean',
'WGgeeignet' => 'Boolean',
'Aufzug' => 'Boolean',
'Garten' => 'Boolean',
'Keller' => 'Boolean',
'StufenloserZugang' => 'Boolean',
'Wohnberechtigungsschein' => 'Boolean',
'HaustiereErlaubt' => 'Boolean',
// Screen 3
'AnzahlVoorzieningen' => 'Int',
'Schlafzimmer' => 'Int',
'Badezimmer' => 'Int',
'EtageVon' => 'Varchar(10)',
'EtageBis' => 'Varchar(10)',
'Nutzflache' => 'Decimal(10,2)',
'WohnberechtigungsscheinErforderlich' => 'Boolean',
'Etagenheizung' => 'Boolean',
'Zentralheizung' => 'Boolean',
// Screen 4
'Internetgeschwindigkeit' => "Enum('Alle,100,250,1000','Alle')",
'Energieeffizienzklasse' => "Enum('A,B,C,D+','A')",
];
private static $belongs_to = [
'Apartment' => Apartment::class . '.Detail',
];
private static $summary_fields = [
'Wohnflache' => 'Wohnfläche',
'Zimmer' => 'Zimmer',
'Kaltmiete' => 'Kaltmiete',
'Gesamtmiete' => 'Gesamtmiete',
'QualitatDerWohnung' => 'Qualität',
'Schlafzimmer' => 'Schlafzimmer',
'Badezimmer' => 'Badezimmer',
'Internetgeschwindigkeit' => 'Internet',
'Energieeffizienzklasse' => 'Energieklasse'
];
}
—
### 📌 Show Page Heading in Template
In your `.ss` template (e.g. `Page.ss` or a custom template for `ApartmentPage`):
<h1>$Title</h1>
👉 If you want the apartment type or object number as heading:
<h1>$Apartment.WohnungType – $Apartment.Objektnummer</h1>
or if you are looping:
<% loop $Apartments %>
<h2>$WohnungType – $Objektnummer</h2>
<% end_loop %>
—
⚡ Question: Do you want **each form screen to be stored in the same `ApartmentDetails` table** (like above) or split into **separate related tables** (e.g. `ApartmentAmenities`, `ApartmentHeating`, etc.)?
Inserat erstellen zu Vermietung einer Wohnung
—
### 📌 How to get this in SilverStripe:
If this is a **Page type** (e.g. `ApartmentPage`), you can print it in the template like:
<h1>$Title</h1>
If you want a **custom heading** separate from `$Title`, add a DB field to the Page model:
private static $db = [
'Heading' => 'Varchar(255)'
];
and in template:
<h1>$Heading</h1>
—
👉 Do you want the heading to always come from the **Page Title** (`$Title`) or should it be a **custom editable field** in CMS (like `$Heading`)?
$fields = FieldList::create(
TextField::create('ObjectNumber', 'Objektnummer')->setAttribute('placeholder', 'Objektnummer*'),
TextField::create('GroupNumber', 'Gruppennummer')->setAttribute('placeholder', 'Gruppennummer*'),
OptionsetField::create('WohnungType', 'Wohnung Type*', [
'Wohnung' => 'Wohnung',
'WG Zimmer' => 'WG Zimmer',
'Haus' => 'Haus',
'Wohnung auf Zeit' => 'Wohnung auf Zeit',
]
)->addExtraClass('radio_group'),
);
$actions = FieldList::create(
FormAction::create('doStep1', 'Weiter')->addExtraClass('btn btn-primary')
);
$validator = RequiredFields::create(['ObjectNumber', 'GroupNumber','WohnungType']);
$form = Form::create($this, 'step1Form', $fields, $actions, $validator);
$form->setFormAction('/apartment/doStep1');
return $form;
}
I also want to show label
In SilverStripe, when you create fields like
TextField::create('ObjectNumber', 'Objektnummer')
the **second parameter** (`'Objektnummer'`) is already the **label**.
By default, SilverStripe renders it as a “ above the input.
—
### Why you might not see labels
– If your theme/CSS hides them (common with frontend frameworks).
– Or if you only see the **placeholder** because you set it.
—
### ✅ Fix: Always show labels
Change your field setup like this:
$fields = FieldList::create(
TextField::create('ObjectNumber', 'Objektnummer')
->setAttribute('placeholder', 'Objektnummer*')
->setTitle('Objektnummer'), // make sure label is rendered
TextField::create('GroupNumber', 'Gruppennummer')
->setAttribute('placeholder', 'Gruppennummer*')
->setTitle('Gruppennummer'),
OptionsetField::create('WohnungType', 'Wohnung Type*', [
'Wohnung' => 'Wohnung',
'WG Zimmer' => 'WG Zimmer',
'Haus' => 'Haus',
'Wohnung auf Zeit' => 'Wohnung auf Zeit',
])->addExtraClass('radio_group')
);
—
### ✅ Ensure template outputs label
If you use `$Form` in template:
$Form
SilverStripe will automatically render `Objektnummer`.
If you’re custom rendering fields, use:
$ObjectNumber.Title <!-- outputs "Objektnummer" -->
$ObjectNumber.Field <!-- outputs the input -->
—
👉 Do you want me to give you a **custom HTML template snippet** so you can control how labels + inputs appear (instead of relying on SilverStripe default form rendering)?
You want to:
1. Save `Apartment` in **Step 1**.
2. Get its **ID** (`$apartment->ID`).
3. Pass it to **Step 2** so you can update related info (`AddressID`, `DetailID`, etc.).
—
### ✅ Step 1 – Save & Redirect with ID
public function doStep1($data, $form)
{
$apartment = Apartment::create();
$form->saveInto($apartment);
$apartment->write();
// Get new DB row ID
$apartmentID = $apartment->ID;
// Redirect to step 2, passing ID
return $this->redirect("apartment/step2/$apartmentID");
}
—
### ✅ Step 2 – Load Apartment & Show Form
public function step2($request)
{
$apartmentID = $request->param('ID');
$apartment = Apartment::get()->byID($apartmentID);
if (!$apartment) {
return $this->httpError(404, 'Apartment not found');
}
return $this->customise([
'Apartment' => $apartment
])->renderWith(['Step2Page', 'Page']);
}
—
### ✅ Step 2 Form – Update AddressID
public function step2Form($request)
{
$apartmentID = $request->param('ID');
$fields = FieldList::create(
TextField::create('AddressID', 'Adresse ID')
);
$actions = FieldList::create(
FormAction::create('doStep2', 'Weiter')
);
$form = Form::create($this, 'step2Form', $fields, $actions);
$form->setFormAction("/apartment/doStep2/$apartmentID");
return $form;
}
public function doStep2($data, $form, $request)
{
$apartmentID = $request->param('ID');
$apartment = Apartment::get()->byID($apartmentID);
if ($apartment) {
$apartment->AddressID = $data['AddressID'];
$apartment->write();
}
return $this->redirect("apartment/step3/$apartmentID");
}
—
✅ This way:
– Step 1 saves → gets `$apartment->ID` → redirect with `/apartment/step2/123`.
– Step 2 form knows the apartment ID → updates `AddressID`.
– You can repeat this pattern for `DetailsID`, `DocumentID`, etc.
—
Do you want me to also set this up so **all steps share the same record via Session** (instead of passing the ID in URL)?
Get by MemberID
Right now you’re doing:
$apartment = Apartment::get()->byID($addressID);
That works only if you have the **Apartment row ID**.
If you want to fetch by **MemberID** instead, you should use `filter()`.
—
### ✅ Example: Get by MemberID
use SilverStripeSecuritySecurity;
$member = Security::getCurrentUser();
if ($member) {
$apartment = Apartment::get()->filter('MemberID', $member->ID)->first();
}
—
### ✅ Or: If you already have `$memberID`
$apartment = Apartment::get()->filter('MemberID', $memberID)->first();
—
### ✅ If you expect multiple apartments per member
$apartments = Apartment::get()->filter('MemberID', $memberID);
foreach ($apartments as $apt) {
// do something
}
—
⚡ Question: do you want only the **first apartment** per member (`->first()`), or should we allow **multiple apartments** for the same member?
$member = Security::getCurrentUser();
$apartment = Apartment::get()->filter('MemberID', $member->ID)->first();
$form=$this->step1Form();
if ($member) {
return $this->customise([
'Form' => $form,
'Title' => 'Inserat erstellen zu Vermietung einer Wohnung',
'ObjectNumber'=>$form->Fields()->dataFieldByName('ObjectNumber')->setValue($apartment->ObjectNumber),
'GroupNumber'=>$form->Fields()->dataFieldByName('GroupNumber')->setValue($apartment->GroupNumber),
'WohnungType'=>$form->Fields()->dataFieldByName('WohnungType')->setValue($apartment->WohnungType),
])->renderWith(['Layout/Apartment/step1', 'Page']);
}else{
return $this->redirect('/login');
}
}
What if we want to use same form for both add / update
### 1) Build the form once (include hidden `ID`)
use SilverStripeFormsHiddenField;
public function step1Form(?Apartment $record = null)
{
$fields = FieldList::create(
HiddenField::create('ID'), // <- lets the handler know if we're updating
TextField::create('ObjectNumber', 'Objektnummer')->setAttribute('placeholder','Objektnummer*'),
TextField::create('GroupNumber', 'Gruppennummer')->setAttribute('placeholder','Gruppennummer*'),
OptionsetField::create('WohnungType', 'Wohnung Type*', [
'Wohnung' => 'Wohnung',
'WG Zimmer' => 'WG Zimmer',
'Haus' => 'Haus',
'Wohnung auf Zeit' => 'Wohnung auf Zeit',
])->addExtraClass('radio_group')
);
$actions = FieldList::create(FormAction::create('saveStep1', 'Weiter'));
$validator = RequiredFields::create(['ObjectNumber','GroupNumber','WohnungType']);
$form = Form::create($this, 'step1Form', $fields, $actions, $validator);
$form->setFormAction($this->Link('saveStep1')); // same endpoint for add & update
// If editing, preload values
if ($record) {
$form->loadDataFrom($record);
}
return $form;
}
### 2) Add page actions for **Add** and **Edit** that both reuse the same form
public function addapartment(HTTPRequest $request)
{
$this->failIfNotLoggedIn();
// Blank form (create)
return $this->customise([
'Title' => 'Inserat erstellen zu Vermietung einer Wohnung',
'Form' => $this->step1Form()
])->renderWith(['Layout/Apartment/step1','Page']);
}
public function editapartment(HTTPRequest $request)
{
$this->failIfNotLoggedIn();
$id = (int)$request->param('ID');
$memberID = Security::getCurrentUser()->ID;
$apartment = Apartment::get()->filter(['ID' => $id, 'MemberID' => $memberID])->first();
if (!$apartment) return $this->httpError(404, 'Apartment not found');
// Same form, prefilled
return $this->customise([
'Title' => 'Wohnung bearbeiten',
'Form' => $this->step1Form($apartment)
])->renderWith(['Layout/Apartment/step1','Page']);
}
private function failIfNotLoggedIn()
{
if (!Security::getCurrentUser()) $this->redirect('/login');
}
### 3) Single handler that **creates or updates** based on `ID`
public function saveStep1($data, Form $form, HTTPRequest $request)
{
$member = Security::getCurrentUser();
if (!$member) return $this->redirect('/login');
// If ID present → update; otherwise create
if (!empty($data['ID'])) {
$apartment = Apartment::get()->filter([
'ID' => (int)$data['ID'],
'MemberID' => $member->ID
])->first();
if (!$apartment) return $this->httpError(404, 'Apartment not found');
} else {
$apartment = Apartment::create();
$apartment->MemberID = $member->ID;
}
$form->saveInto($apartment);
$apartment->write();
// Use the same redirect path for both cases
return $this->redirect("apartment/step2/{$apartment->ID}");
}
**Why this works**
– The hidden `ID` tells the handler when to **update**.
– `loadDataFrom($record)` pre-fills the same form when editing.
– One action (`saveStep1`) handles both **add** and **update** paths cleanly.
### 1) Enforce “one apartment per member”
In `Apartment.php`:
private static $db = [
'Objektnummer' => 'Varchar(50)',
'Gruppennummer'=> 'Varchar(50)',
'WohnungType' => "Enum('Wohnung,WG Zimmer,Haus,Wohnung auf Zeit','Wohnung')",
'GarageOderStellplatz'=> 'Boolean',
'MemberID' => 'Int',
];
private static $indexes = [
'MemberIDUnique' => [
'type' => 'unique',
'columns' => ['MemberID'],
],
];
*(The unique index guarantees only one row per member in DB.)*
—
### 2) One form for both add/update (no IDs in URL)
use SilverStripeSecuritySecurity;
use SilverStripeForms{FieldList,TextField,OptionsetField,FormAction,RequiredFields,Form,HiddenField};
use SilverStripeControlHTTPRequest;
public function step1Form(?Apartment $record = null)
{
$fields = FieldList::create(
// keep Hidden ID optional; we won’t use it for routing, but it helps Form::saveInto for updates
HiddenField::create('ID'),
TextField::create('ObjectNumber', 'Objektnummer')->setAttribute('placeholder','Objektnummer*'),
TextField::create('GroupNumber', 'Gruppennummer')->setAttribute('placeholder','Gruppennummer*'),
OptionsetField::create('WohnungType', 'Wohnung Type*', [
'Wohnung' => 'Wohnung',
'WG Zimmer' => 'WG Zimmer',
'Haus' => 'Haus',
'Wohnung auf Zeit' => 'Wohnung auf Zeit',
])->addExtraClass('radio_group')
);
$actions = FieldList::create(FormAction::create('saveStep1', 'Weiter'));
$validator = RequiredFields::create(['ObjectNumber','GroupNumber','WohnungType']);
$form = Form::create($this, 'step1Form', $fields, $actions, $validator);
$form->setFormAction($this->Link('saveStep1'));
if ($record) {
$form->loadDataFrom($record);
// ensure the hidden ID is present so saveInto updates
$form->Fields()->dataFieldByName('ID')?->setValue($record->ID);
}
return $form;
}
—
### 3) “Add” and “Edit” pages both use the same source of truth (MemberID)
public function addapartment(HTTPRequest $request)
{
$member = Security::getCurrentUser();
if (!$member) return $this->redirect('/login');
// Find existing by MemberID (or null for blank form)
$apartment = Apartment::get()->filter('MemberID', $member->ID)->first();
return $this->customise([
'Title' => 'Inserat erstellen zu Vermietung einer Wohnung',
'Form' => $this->step1Form($apartment)
])->renderWith(['Layout/Apartment/step1','Page']);
}
public function editapartment(HTTPRequest $request)
{
// same as addapartment; we just reuse it
return $this->addapartment($request);
}
—
### 4) Single save handler (find-or-create by MemberID)
public function saveStep1($data, Form $form, HTTPRequest $request)
{
$member = Security::getCurrentUser();
if (!$member) return $this->redirect('/login');
// Find existing by MemberID
$apartment = Apartment::get()->filter('MemberID', $member->ID)->first();
if (!$apartment) {
$apartment = Apartment::create();
$apartment->MemberID = $member->ID;
}
// Let form values overwrite fields
$form->saveInto($apartment);
$apartment->write();
// move to next step WITHOUT any IDs in URL
return $this->redirect($this->Link('step2'));
}
—
### 5) Step 2 & beyond: always fetch by MemberID
public function step2(HTTPRequest $request)
{
$member = Security::getCurrentUser();
if (!$member) return $this->redirect('/login');
$apartment = Apartment::get()->filter('MemberID', $member->ID)->first();
if (!$apartment) return $this->redirect($this->Link('addapartment')); // or 404
// render step 2 form (address, etc.)
return $this->customise([
'Title' => 'Adresse hinzufügen',
'Apartment' => $apartment,
'Form' => $this->step2Form($apartment) // your form builder using same pattern
])->renderWith(['Layout/Apartment/step2','Page']);
}
public function saveStep2($data, Form $form, HTTPRequest $request)
{
$member = Security::getCurrentUser();
if (!$member) return $this->redirect('/login');
$apartment = Apartment::get()->filter('MemberID', $member->ID)->first();
if (!$apartment) return $this->httpError(404, 'Apartment not found');
// e.g. $apartment->AddressID = $data['AddressID'] ?? 0;
$form->saveInto($apartment);
$apartment->write();
return $this->redirect($this->Link('step3'));
}
—
### Why this fits your requirement
– **No apartment ID** is ever exposed or required.
– The **current member** is the only key you need.
– The **unique index** guarantees data integrity.
– The same form/handler works for **create + update** seamlessly.