diff --git a/ProcessMaker/Http/Controllers/Api/EnvironmentVariablesController.php b/ProcessMaker/Http/Controllers/Api/EnvironmentVariablesController.php
index b599f49795..ffd892d65d 100644
--- a/ProcessMaker/Http/Controllers/Api/EnvironmentVariablesController.php
+++ b/ProcessMaker/Http/Controllers/Api/EnvironmentVariablesController.php
@@ -104,10 +104,15 @@ public function index(Request $request)
*/
public function store(Request $request)
{
- $request->validate(EnvironmentVariable::rules(), EnvironmentVariable::messages());
- $environment_variable = EnvironmentVariable::create($request->all());
+ $data = $this->prepareAssetLinkInput($request->all());
+ validator($data, EnvironmentVariable::rules(), EnvironmentVariable::messages())->validate();
+ $linkedAsset = EnvironmentVariable::validateAssetLinkConsistency($data);
+ if ($linkedAsset) {
+ $data['value'] = (string) $linkedAsset->id;
+ }
+ $environment_variable = EnvironmentVariable::create($data);
// Register the Event
- EnvironmentVariablesCreated::dispatch($request->all());
+ EnvironmentVariablesCreated::dispatch($data);
return new EnvironmentVariableResource($environment_variable);
}
@@ -172,14 +177,22 @@ public function show(EnvironmentVariable $environment_variable)
*/
public function update(EnvironmentVariable $environment_variable, Request $request)
{
- $fields = ['name', 'description'];
- if ($request->filled('value')) {
+ $data = $this->prepareAssetLinkInput($request->all());
+ // Validate the request, passing in the existing variable to tweak unique rule on name
+ validator($data, EnvironmentVariable::rules($environment_variable), EnvironmentVariable::messages())->validate();
+ $linkedAsset = EnvironmentVariable::validateAssetLinkConsistency($data);
+
+ $fields = ['name', 'description', 'asset_type', 'do_not_update'];
+ if ($linkedAsset) {
+ // Guarantee value is always the selected asset's ID on this instance.
+ $data['value'] = (string) $linkedAsset->id;
+ $fields[] = 'value';
+ } elseif (array_key_exists('value', $data) && $data['value'] !== null && $data['value'] !== '') {
$fields[] = 'value';
}
- // Validate the request, passing in the existing variable to tweak unique rule on name
- $request->validate(EnvironmentVariable::rules($environment_variable));
+
$original = $environment_variable->getOriginal();
- $environment_variable->fill($request->only($fields));
+ $environment_variable->fill(collect($data)->only($fields)->all());
$environment_variable->save();
$changes = $environment_variable->getChanges();
@@ -190,6 +203,18 @@ public function update(EnvironmentVariable $environment_variable, Request $reque
return new EnvironmentVariableResource($environment_variable);
}
+ /**
+ * Normalize empty asset_type to null.
+ */
+ private function prepareAssetLinkInput(array $data): array
+ {
+ if (array_key_exists('asset_type', $data) && $data['asset_type'] === '') {
+ $data['asset_type'] = null;
+ }
+
+ return $data;
+ }
+
/**
* @OA\Delete(
* path="/environment_variables/{environment_variable_id}",
diff --git a/ProcessMaker/ImportExport/DependentType.php b/ProcessMaker/ImportExport/DependentType.php
index acd2db4e9b..f5000fe6a9 100644
--- a/ProcessMaker/ImportExport/DependentType.php
+++ b/ProcessMaker/ImportExport/DependentType.php
@@ -22,6 +22,8 @@ abstract class DependentType
const ENVIRONMENT_VARIABLE_VALUE = 'environment_variables_value';
+ const ENVIRONMENT_VARIABLE_ASSET = 'environment_variable_asset';
+
const SCRIPT_EXECUTORS = 'script_executors';
const SUB_PROCESSES = 'sub_processes';
diff --git a/ProcessMaker/ImportExport/Exporters/EnvironmentVariableExporter.php b/ProcessMaker/ImportExport/Exporters/EnvironmentVariableExporter.php
index 2161c90b46..ed1637ad45 100644
--- a/ProcessMaker/ImportExport/Exporters/EnvironmentVariableExporter.php
+++ b/ProcessMaker/ImportExport/Exporters/EnvironmentVariableExporter.php
@@ -2,7 +2,13 @@
namespace ProcessMaker\ImportExport\Exporters;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Facades\Log;
+use ProcessMaker\Enums\ExporterMap;
use ProcessMaker\ImportExport\DependentType;
+use ProcessMaker\ImportExport\Manifest;
+use ProcessMaker\ImportExport\Options;
+use ProcessMaker\ImportExport\Psudomodels\Psudomodel;
class EnvironmentVariableExporter extends ExporterBase
{
@@ -14,13 +20,75 @@ class EnvironmentVariableExporter extends ExporterBase
public $incrementStringSeparator = '_';
+ public function __construct(
+ Model|Psudomodel|null $model,
+ Manifest $manifest,
+ Options $options,
+ $ignoreExplicitDiscard
+ ) {
+ parent::__construct($model, $manifest, $options, $ignoreExplicitDiscard);
+
+ // PHP does not allow closures as property defaults.
+ $this->discard = fn ($envVar) => (bool) ($envVar->do_not_update ?? false);
+ }
+
public function export() : void
{
$this->addReference(DependentType::ENVIRONMENT_VARIABLE_VALUE, $this->model->value);
+
+ if (!$this->model->hasLinkedAsset()) {
+ return;
+ }
+
+ $asset = $this->model->resolveLinkedAsset();
+ if (!$asset) {
+ Log::warning('EnvironmentVariableExporter: linked asset not found during export', [
+ 'environment_variable' => $this->model->name,
+ 'asset_type' => $this->model->asset_type,
+ 'value' => $this->model->value,
+ ]);
+
+ return;
+ }
+
+ $exporterClass = ExporterMap::getExporterClassForModel($asset);
+ if (!$exporterClass) {
+ Log::warning('EnvironmentVariableExporter: no exporter registered for linked asset', [
+ 'environment_variable' => $this->model->name,
+ 'asset_type' => $this->model->asset_type,
+ ]);
+
+ return;
+ }
+
+ $this->addDependent(DependentType::ENVIRONMENT_VARIABLE_ASSET, $asset, $exporterClass);
}
public function import() : bool
{
+ foreach ($this->getDependents(DependentType::ENVIRONMENT_VARIABLE_ASSET, true) as $dependent) {
+ $asset = $dependent->model;
+ if ($asset && $asset->exists) {
+ $this->model->asset_type = get_class($asset);
+ $this->model->value = (string) $asset->id;
+
+ return $this->model->save();
+ }
+ }
+
+ if ($this->model->asset_type) {
+ // Linked in source, but asset was not imported and was not found on target.
+ $this->logger?->addWarning(__(
+ 'Asset linked to environment variable ":env_variable" was missing on import; link and value were cleared',
+ ['env_variable' => $this->model->name]
+ ));
+ $this->model->asset_type = null;
+ $this->model->value = '';
+
+ return $this->model->save();
+ }
+
+ // Standard case (non-linked) env var: restore secret/value from export.
$this->model->value = $this->getReference(DependentType::ENVIRONMENT_VARIABLE_VALUE);
return $this->model->save();
diff --git a/ProcessMaker/ImportExport/Exporters/ExporterBase.php b/ProcessMaker/ImportExport/Exporters/ExporterBase.php
index f4ac9c4d90..e951581bee 100644
--- a/ProcessMaker/ImportExport/Exporters/ExporterBase.php
+++ b/ProcessMaker/ImportExport/Exporters/ExporterBase.php
@@ -47,6 +47,12 @@ abstract class ExporterBase implements ExporterInterface
public $hidden = false;
+ /**
+ * When true (or a callable returning true), this asset is explicitly discarded.
+ * Callables receive the model: fn (Model $model): bool
+ *
+ * @var bool|callable
+ */
public $discard = false;
public static $forceUpdate = false;
@@ -135,7 +141,7 @@ public function uuid() : string
public function include() : bool
{
- if ($this->discard) { // Explicit Discard (not from passed-in options)
+ if ($this->isExplicitlyDiscarded()) { // Explicit Discard (not from passed-in options)
if (!$this->ignoreExplicitDiscard) {
return false;
}
@@ -148,6 +154,40 @@ public function include() : bool
return true;
}
+ /**
+ * Resolve $discard whether it is a boolean or a callable checked against the model.
+ */
+ public function isExplicitlyDiscarded(): bool
+ {
+ if (is_callable($this->discard)) {
+ return (bool) ($this->discard)($this->model);
+ }
+
+ return (bool) $this->discard;
+ }
+
+ /**
+ * Import-time check: should an existing target model be discarded (not overwritten)?
+ *
+ * Only callables are evaluated here. Boolean `$discard = true` (Users/Groups) is an
+ * export-time flag handled by include(), not an import-time skip.
+ * Psudomodels (e.g. Signal) are never discarded by this path.
+ */
+ public static function shouldDiscardExistingModelDuringImport(Model|Psudomodel|null $model): bool
+ {
+ if (!$model instanceof Model || !$model->exists) {
+ return false;
+ }
+
+ $exporter = new static($model, new Manifest(), new Options([]), false);
+
+ if (!is_callable($exporter->discard)) {
+ return false;
+ }
+
+ return (bool) ($exporter->discard)($model);
+ }
+
public function addDependent(string $type, Model|Psudomodel $dependentModel, string $exporterClass, $meta = null)
{
$uuid = $dependentModel->uuid;
@@ -304,7 +344,7 @@ public function toArray()
'hidden' => $this->hidden,
'mode' => $this->mode,
'saveAssetsMode' => $this->saveAssetsMode,
- 'explicit_discard' => $this->discard,
+ 'explicit_discard' => $this->isExplicitlyDiscarded(),
'dependents' => array_map(fn ($d) => $d->toArray(), $this->dependents),
'name' => $this->getName($this->model),
'description' => $this->getDescription(),
diff --git a/ProcessMaker/ImportExport/Manifest.php b/ProcessMaker/ImportExport/Manifest.php
index 494d3197b5..ac5372b777 100644
--- a/ProcessMaker/ImportExport/Manifest.php
+++ b/ProcessMaker/ImportExport/Manifest.php
@@ -158,6 +158,11 @@ public static function getModel($uuid, $assetInfo, $mode, $exporterClass, $isRoo
$mode = 'discard';
}
+ // Per-asset explicit discard (e.g. env vars with do_not_update on the target).
+ if ($model && $exporterClass::shouldDiscardExistingModelDuringImport($model)) {
+ $mode = 'discard';
+ }
+
if ($exporterClass::$forceUpdate) {
$mode = 'update';
}
diff --git a/ProcessMaker/Models/EnvironmentVariable.php b/ProcessMaker/Models/EnvironmentVariable.php
index 8aaea1cf0c..2cee3a1597 100644
--- a/ProcessMaker/Models/EnvironmentVariable.php
+++ b/ProcessMaker/Models/EnvironmentVariable.php
@@ -3,8 +3,11 @@
namespace ProcessMaker\Models;
use Exception;
+use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
+use Illuminate\Validation\ValidationException;
+use ProcessMaker\Enums\ExporterMap;
use ProcessMaker\Traits\Exportable;
/**
@@ -13,6 +16,8 @@
* @OA\Property(property="name", type="string"),
* @OA\Property(property="description", type="string"),
* @OA\Property(property="value", type="string"),
+ * @OA\Property(property="asset_type", type="string", nullable=true),
+ * @OA\Property(property="do_not_update", type="boolean"),
* ),
* @OA\Schema(
* schema="EnvironmentVariable",
@@ -36,12 +41,27 @@ class EnvironmentVariable extends ProcessMakerModel
'name',
'description',
'value',
+ 'asset_type',
+ 'do_not_update',
+ ];
+
+ protected $casts = [
+ 'do_not_update' => 'boolean',
];
protected $hidden = [
'value',
];
+ protected static function boot()
+ {
+ parent::boot();
+
+ static::saving(function (self $environmentVariable) {
+ $environmentVariable->normalizeAssetLink();
+ });
+ }
+
/**
* Store the encrypted version of the variable value here
*/
@@ -74,21 +94,118 @@ public static function rules($existing = null)
{
$unique = Rule::unique('environment_variables')->ignore($existing);
$validVariableName = '/^[a-zA-Z][a-zA-Z_$0-9]*$/';
+ $allowedAssetTypes = self::allowedAssetTypes();
return [
'description' => 'required',
- 'value' => 'nullable',
+ 'value' => [
+ 'nullable',
+ 'required_with:asset_type',
+ ],
'name' => ['required', "regex:{$validVariableName}", $unique],
+ 'asset_type' => [
+ 'nullable',
+ 'string',
+ Rule::in($allowedAssetTypes),
+ ],
+ 'do_not_update' => 'boolean',
];
}
+ /**
+ * Validate that asset_type + value resolve to an existing asset of that type.
+ *
+ * @return Model|null The resolved asset when a link is present
+ */
+ public static function validateAssetLinkConsistency(array $data): ?Model
+ {
+ $assetType = $data['asset_type'] ?? null;
+ $value = $data['value'] ?? null;
+
+ if (!$assetType) {
+ return null;
+ }
+
+ if ($value === null || $value === '') {
+ throw ValidationException::withMessages([
+ 'value' => [trans('environmentVariables.validation.value.required_with_asset_type')],
+ ]);
+ }
+
+ if (!class_exists($assetType)) {
+ throw ValidationException::withMessages([
+ 'asset_type' => [__('The selected asset type is not available.')],
+ ]);
+ }
+
+ if (!in_array($assetType, self::allowedAssetTypes(), true)) {
+ throw ValidationException::withMessages([
+ 'asset_type' => [__('The selected asset type is not allowed.')],
+ ]);
+ }
+
+ $asset = $assetType::find($value);
+ if (!$asset) {
+ throw ValidationException::withMessages([
+ 'value' => [trans('environmentVariables.validation.value.not_found_for_type')],
+ ]);
+ }
+
+ // Guard against class hierarchy mismatches (e.g. subclass stored under a parent type).
+ if (get_class($asset) !== $assetType) {
+ throw ValidationException::withMessages([
+ 'asset_type' => [trans('environmentVariables.validation.asset_type.mismatch')],
+ ]);
+ }
+
+ return $asset;
+ }
+
public static function messages()
{
return [
'name.regex' => trans('environmentVariables.validation.name.invalid_variable_name'),
+ 'value.required_with' => trans('environmentVariables.validation.value.required_with_asset_type'),
];
}
+ /**
+ * Exportable model classes that may be linked to an environment variable.
+ */
+ public static function allowedAssetTypes(): array
+ {
+ return collect(ExporterMap::TYPES)
+ ->map(fn (array $type) => $type[0])
+ ->filter(fn (string $class) => class_exists($class))
+ ->values()
+ ->all();
+ }
+
+ public function resolveLinkedAsset(): ?Model
+ {
+ if (!$this->hasLinkedAsset() || !class_exists($this->asset_type)) {
+ return null;
+ }
+
+ if ($this->value === null || $this->value === '') {
+ return null;
+ }
+
+ return $this->asset_type::find($this->value);
+ }
+
+ public function hasLinkedAsset(): bool
+ {
+ return !empty($this->asset_type);
+ }
+
+ protected function normalizeAssetLink(): void
+ {
+ if ($this->asset_type === '') {
+ $this->asset_type = null;
+ }
+ }
+
public static function getMetricsApiEndpoint()
{
$variable = self::where('name', 'METRICS_API_ENDPOINT')->first();
diff --git a/database/factories/ProcessMaker/Models/EnvironmentVariableFactory.php b/database/factories/ProcessMaker/Models/EnvironmentVariableFactory.php
index 9c63ccdf2f..fbdf253ce9 100644
--- a/database/factories/ProcessMaker/Models/EnvironmentVariableFactory.php
+++ b/database/factories/ProcessMaker/Models/EnvironmentVariableFactory.php
@@ -18,6 +18,7 @@ public function definition()
'name' => $this->faker->word(),
'description' => $this->faker->sentence(),
'value' => $this->faker->sentence(),
+ 'do_not_update' => false,
];
}
}
diff --git a/database/migrations/2026_07_20_000000_add_asset_link_to_environment_variables_table.php b/database/migrations/2026_07_20_000000_add_asset_link_to_environment_variables_table.php
new file mode 100644
index 0000000000..370df325dd
--- /dev/null
+++ b/database/migrations/2026_07_20_000000_add_asset_link_to_environment_variables_table.php
@@ -0,0 +1,28 @@
+string('asset_type')->nullable()->after('value');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('environment_variables', function (Blueprint $table) {
+ $table->dropColumn(['asset_type']);
+ });
+ }
+};
diff --git a/database/migrations/2026_07_23_000000_add_do_not_update_to_environment_variables_table.php b/database/migrations/2026_07_23_000000_add_do_not_update_to_environment_variables_table.php
new file mode 100644
index 0000000000..122397d593
--- /dev/null
+++ b/database/migrations/2026_07_23_000000_add_do_not_update_to_environment_variables_table.php
@@ -0,0 +1,28 @@
+boolean('do_not_update')->default(false)->after('asset_type');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('environment_variables', function (Blueprint $table) {
+ $table->dropColumn('do_not_update');
+ });
+ }
+};
diff --git a/resources/js/processes/environment-variables/assetTypes.js b/resources/js/processes/environment-variables/assetTypes.js
new file mode 100644
index 0000000000..e03a0892bf
--- /dev/null
+++ b/resources/js/processes/environment-variables/assetTypes.js
@@ -0,0 +1,54 @@
+/**
+ * Exportable asset types that can be linked to an environment variable.
+ * apiPath is the local API listing endpoint used by the asset picker.
+ */
+export default [
+ {
+ class: "ProcessMaker\\Models\\Process",
+ label: "Process",
+ apiPath: "processes",
+ nameField: "name",
+ },
+ {
+ class: "ProcessMaker\\Models\\Screen",
+ label: "Screen",
+ apiPath: "screens",
+ nameField: "title",
+ },
+ {
+ class: "ProcessMaker\\Models\\Script",
+ label: "Script",
+ apiPath: "scripts",
+ nameField: "title",
+ },
+ {
+ class: "ProcessMaker\\Plugins\\Collections\\Models\\Collection",
+ label: "Collection",
+ apiPath: "collections",
+ nameField: "name",
+ },
+ {
+ class: "ProcessMaker\\Packages\\Connectors\\DataSources\\Models\\DataSource",
+ label: "Data Connector",
+ apiPath: "data_sources",
+ nameField: "name",
+ },
+ {
+ class: "ProcessMaker\\Package\\PackageDecisionEngine\\Models\\DecisionTable",
+ label: "Decision Table",
+ apiPath: "decision_tables",
+ nameField: "title",
+ },
+ {
+ class: "ProcessMaker\\Package\\PackageAi\\Models\\FlowGenie",
+ label: "FlowGenie",
+ apiPath: "package-ai/flow_genies",
+ nameField: "name",
+ },
+ {
+ class: "ProcessMaker\\Package\\PackagePmBlocks\\Models\\PmBlock",
+ label: "PM Block",
+ apiPath: "pm-blocks",
+ nameField: "name",
+ },
+];
diff --git a/resources/js/processes/environment-variables/components/AssetLinkFields.vue b/resources/js/processes/environment-variables/components/AssetLinkFields.vue
new file mode 100644
index 0000000000..4164373a7c
--- /dev/null
+++ b/resources/js/processes/environment-variables/components/AssetLinkFields.vue
@@ -0,0 +1,233 @@
+
+