diff --git a/lib/modules/manager/composer/artifacts.ts b/lib/modules/manager/composer/artifacts.ts
index cc6acbbf266ff03cd33f3f20c25bca3e15b1a203..a902bf827db02361d7029841aa7180fe6ca3a167 100644
--- a/lib/modules/manager/composer/artifacts.ts
+++ b/lib/modules/manager/composer/artifacts.ts
@@ -1,5 +1,6 @@
 import is from '@sindresorhus/is';
 import { quote } from 'shlex';
+import { z } from 'zod';
 import {
   SYSTEM_INSUFFICIENT_DISK_SPACE,
   TEMPORARY_ERROR,
@@ -18,10 +19,12 @@ import {
 import { getRepoStatus } from '../../../util/git';
 import * as hostRules from '../../../util/host-rules';
 import { regEx } from '../../../util/regex';
+import { Json } from '../../../util/schema-utils';
 import { GitTagsDatasource } from '../../datasource/git-tags';
 import { PackagistDatasource } from '../../datasource/packagist';
 import type { UpdateArtifact, UpdateArtifactsResult } from '../types';
-import type { AuthJson, ComposerLock } from './types';
+import { Lockfile, PackageFile } from './schema';
+import type { AuthJson } from './types';
 import {
   extractConstraints,
   findGithubToken,
@@ -105,10 +108,19 @@ export async function updateArtifacts({
 }: UpdateArtifact): Promise<UpdateArtifactsResult[] | null> {
   logger.debug(`composer.updateArtifacts(${packageFileName})`);
 
+  const file = Json.pipe(PackageFile).parse(newPackageFileContent);
+
   const lockFileName = packageFileName.replace(regEx(/\.json$/), '.lock');
-  const existingLockFileContent = await readLocalFile(lockFileName, 'utf8');
-  if (!existingLockFileContent) {
-    logger.debug('No composer.lock found');
+  const lockfile = await z
+    .string()
+    .transform((f) => readLocalFile(f, 'utf8'))
+    .pipe(Json)
+    .pipe(Lockfile)
+    .nullable()
+    .catch(null)
+    .parseAsync(lockFileName);
+  if (!lockfile) {
+    logger.debug('Composer: unable to read lockfile');
     return null;
   }
 
@@ -118,12 +130,8 @@ export async function updateArtifacts({
   try {
     await writeLocalFile(packageFileName, newPackageFileContent);
 
-    const existingLockFile: ComposerLock = JSON.parse(existingLockFileContent);
     const constraints = {
-      ...extractConstraints(
-        JSON.parse(newPackageFileContent),
-        existingLockFile
-      ),
+      ...extractConstraints(file, lockfile),
       ...config.constraints,
     };
 
@@ -150,7 +158,7 @@ export async function updateArtifacts({
     const commands: string[] = [];
 
     // Determine whether install is required before update
-    if (requireComposerDependencyInstallation(existingLockFile)) {
+    if (requireComposerDependencyInstallation(lockfile)) {
       const preCmd = 'composer';
       const preArgs =
         'install' + getComposerArguments(config, composerToolConstraint);
diff --git a/lib/modules/manager/composer/extract.spec.ts b/lib/modules/manager/composer/extract.spec.ts
index 617db0e7245fe2bd0e6d752de35936803f3422f7..9c7d71a4cfd14bf839db3f1aaa1f4404659cc93a 100644
--- a/lib/modules/manager/composer/extract.spec.ts
+++ b/lib/modules/manager/composer/extract.spec.ts
@@ -279,7 +279,7 @@ describe('modules/manager/composer/extract', () => {
     });
 
     it('extracts dependencies with lock file', async () => {
-      fs.readLocalFile.mockResolvedValue('some content');
+      fs.readLocalFile.mockResolvedValue('{}');
       const res = await extractPackageFile(requirements1, packageFile);
       expect(res).toMatchSnapshot();
       expect(res?.deps).toHaveLength(33);
diff --git a/lib/modules/manager/composer/extract.ts b/lib/modules/manager/composer/extract.ts
index ae40abec46924968a463626b2ddd7a4e9c10ec40..22eb4bdae316d41fdbff92255f69390265f60f3a 100644
--- a/lib/modules/manager/composer/extract.ts
+++ b/lib/modules/manager/composer/extract.ts
@@ -1,215 +1,15 @@
-import is from '@sindresorhus/is';
 import { logger } from '../../../logger';
-import { readLocalFile } from '../../../util/fs';
-import { regEx } from '../../../util/regex';
-import { GitTagsDatasource } from '../../datasource/git-tags';
-import { GithubTagsDatasource } from '../../datasource/github-tags';
-import { PackagistDatasource } from '../../datasource/packagist';
-import { api as semverComposer } from '../../versioning/composer';
-import type { PackageDependency, PackageFileContent } from '../types';
-import type {
-  ComposerConfig,
-  ComposerLock,
-  ComposerManagerData,
-  ComposerRepositories,
-  Repo,
-} from './types';
-
-/**
- * The regUrl is expected to be a base URL. GitLab composer repository installation guide specifies
- * to use a base URL containing packages.json. Composer still works in this scenario by determining
- * whether to add / remove packages.json from the URL.
- *
- * See https://github.com/composer/composer/blob/750a92b4b7aecda0e5b2f9b963f1cb1421900675/src/Composer/Repository/ComposerRepository.php#L815
- */
-function transformRegUrl(url: string): string {
-  return url.replace(regEx(/(\/packages\.json)$/), '');
-}
-
-/**
- * Parse the repositories field from a composer.json
- *
- * Entries with type vcs or git will be added to repositories,
- * other entries will be added to registryUrls
- */
-function parseRepositories(
-  repoJson: ComposerRepositories,
-  repositories: Record<string, Repo>,
-  registryUrls: string[]
-): void {
-  try {
-    let packagist = true;
-    Object.entries(repoJson).forEach(([key, repo]) => {
-      if (is.object(repo)) {
-        const name = is.array(repoJson) ? repo.name : key;
-
-        switch (repo.type) {
-          case 'vcs':
-          case 'git':
-          case 'path':
-            repositories[name!] = repo;
-            break;
-          case 'composer':
-            registryUrls.push(transformRegUrl(repo.url));
-            break;
-          case 'package':
-            logger.debug(
-              { url: repo.url },
-              'type package is not supported yet'
-            );
-        }
-        if (repo.packagist === false || repo['packagist.org'] === false) {
-          packagist = false;
-        }
-      } // istanbul ignore else: invalid repo
-      else if (['packagist', 'packagist.org'].includes(key) && repo === false) {
-        packagist = false;
-      }
-    });
-    if (packagist) {
-      registryUrls.push('https://packagist.org');
-    } else {
-      logger.debug('Disabling packagist.org');
-    }
-  } catch (e) /* istanbul ignore next */ {
-    logger.debug(
-      { repositories: repoJson },
-      'Error parsing composer.json repositories config'
-    );
-  }
-}
+import type { PackageFileContent } from '../types';
+import { ComposerExtract } from './schema';
 
 export async function extractPackageFile(
   content: string,
   fileName: string
 ): Promise<PackageFileContent | null> {
-  logger.trace(`composer.extractPackageFile(${fileName})`);
-  let composerJson: ComposerConfig;
-  try {
-    composerJson = JSON.parse(content);
-  } catch (err) {
-    logger.debug(`Invalid JSON in ${fileName}`);
+  const res = await ComposerExtract.safeParseAsync({ content, fileName });
+  if (!res.success) {
+    logger.debug({ fileName, err: res.error }, 'Composer: extract failed');
     return null;
   }
-  const repositories: Record<string, Repo> = {};
-  const registryUrls: string[] = [];
-  const res: PackageFileContent = { deps: [] };
-
-  // handle lockfile
-  const lockfilePath = fileName.replace(regEx(/\.json$/), '.lock');
-  const lockContents = await readLocalFile(lockfilePath, 'utf8');
-  let lockParsed: ComposerLock | undefined;
-  if (lockContents) {
-    logger.debug(`Found composer lock file ${fileName}`);
-    res.lockFiles = [lockfilePath];
-    try {
-      lockParsed = JSON.parse(lockContents) as ComposerLock;
-    } catch (err) /* istanbul ignore next */ {
-      logger.warn({ err }, 'Error processing composer.lock');
-    }
-  }
-
-  // handle composer.json repositories
-  if (composerJson.repositories) {
-    parseRepositories(composerJson.repositories, repositories, registryUrls);
-  }
-
-  const deps: PackageDependency[] = [];
-  const depTypes: ('require' | 'require-dev')[] = ['require', 'require-dev'];
-  for (const depType of depTypes) {
-    if (composerJson[depType]) {
-      try {
-        for (const [depName, version] of Object.entries(
-          composerJson[depType]!
-        )) {
-          const currentValue = version.trim();
-          if (depName === 'php') {
-            deps.push({
-              depType,
-              depName,
-              currentValue,
-              datasource: GithubTagsDatasource.id,
-              packageName: 'php/php-src',
-              extractVersion: '^php-(?<version>.*)$',
-            });
-          } else {
-            // Default datasource and packageName
-            let datasource = PackagistDatasource.id;
-            let packageName = depName;
-
-            // Check custom repositories by type
-            if (repositories[depName]) {
-              switch (repositories[depName].type) {
-                case 'vcs':
-                case 'git':
-                  datasource = GitTagsDatasource.id;
-                  packageName = repositories[depName].url;
-                  break;
-                case 'path':
-                  deps.push({
-                    depType,
-                    depName,
-                    currentValue,
-                    skipReason: 'path-dependency',
-                  });
-                  continue;
-              }
-            }
-            const dep: PackageDependency = {
-              depType,
-              depName,
-              currentValue,
-              datasource,
-            };
-            if (depName !== packageName) {
-              dep.packageName = packageName;
-            }
-            if (!depName.includes('/')) {
-              dep.skipReason = 'unsupported';
-            }
-            if (lockParsed) {
-              const lockField =
-                depType === 'require'
-                  ? 'packages'
-                  : /* istanbul ignore next */ 'packages-dev';
-              const lockedDep = lockParsed[lockField]?.find(
-                (item) => item.name === dep.depName
-              );
-              if (lockedDep && semverComposer.isVersion(lockedDep.version)) {
-                dep.lockedVersion = lockedDep.version.replace(regEx(/^v/i), '');
-              }
-            }
-            if (
-              !dep.skipReason &&
-              (!repositories[depName] ||
-                repositories[depName].type === 'composer') &&
-              registryUrls.length !== 0
-            ) {
-              dep.registryUrls = registryUrls;
-            }
-            deps.push(dep);
-          }
-        }
-      } catch (err) /* istanbul ignore next */ {
-        logger.debug({ fileName, depType, err }, 'Error parsing composer.json');
-        return null;
-      }
-    }
-  }
-  if (!deps.length) {
-    return null;
-  }
-  res.deps = deps;
-  if (is.string(composerJson.type)) {
-    const managerData: ComposerManagerData = {
-      composerJsonType: composerJson.type,
-    };
-    res.managerData = managerData;
-  }
-
-  if (composerJson.require?.php) {
-    res.extractedConstraints = { php: composerJson.require.php };
-  }
-
-  return res;
+  return res.data;
 }
diff --git a/lib/modules/manager/composer/schema.spec.ts b/lib/modules/manager/composer/schema.spec.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d105b70690e9263e501ed16dae41300d42c28209
--- /dev/null
+++ b/lib/modules/manager/composer/schema.spec.ts
@@ -0,0 +1,115 @@
+import { Repos, ReposArray, ReposRecord } from './schema';
+
+describe('modules/manager/composer/schema', () => {
+  describe('ReposRecord', () => {
+    it('parses default values', () => {
+      expect(ReposRecord.parse({})).toEqual([]);
+    });
+
+    it('parses repositories', () => {
+      expect(
+        ReposRecord.parse({
+          wpackagist: { type: 'composer', url: 'https://wpackagist.org' },
+          someGit: { type: 'vcs', url: 'https://some-vcs.com' },
+          somePath: { type: 'path', url: '/some/path' },
+          packagist: false,
+          'packagist.org': false,
+          foo: 'bar',
+        })
+      ).toEqual([
+        { type: 'composer', url: 'https://wpackagist.org' },
+        { name: 'someGit', type: 'git', url: 'https://some-vcs.com' },
+        { name: 'somePath', type: 'path', url: '/some/path' },
+        { type: 'disable-packagist' },
+        { type: 'disable-packagist' },
+      ]);
+    });
+  });
+
+  describe('ReposArray', () => {
+    it('parses default values', () => {
+      expect(ReposArray.parse([])).toEqual([]);
+    });
+
+    it('parses repositories', () => {
+      expect(
+        ReposArray.parse([
+          {
+            type: 'composer',
+            url: 'https://wpackagist.org',
+          },
+          { name: 'someGit', type: 'vcs', url: 'https://some-vcs.com' },
+          { name: 'somePath', type: 'path', url: '/some/path' },
+          { packagist: false },
+          { 'packagist.org': false },
+          { foo: 'bar' },
+        ])
+      ).toEqual([
+        { type: 'composer', url: 'https://wpackagist.org' },
+        { name: 'someGit', type: 'git', url: 'https://some-vcs.com' },
+        { name: 'somePath', type: 'path', url: '/some/path' },
+        { type: 'disable-packagist' },
+        { type: 'disable-packagist' },
+      ]);
+    });
+  });
+
+  describe('Repos', () => {
+    it('parses default values', () => {
+      expect(Repos.parse(null)).toEqual({
+        pathRepos: {},
+        gitRepos: {},
+        registryUrls: null,
+      });
+    });
+
+    it('parses repositories', () => {
+      expect(
+        Repos.parse([
+          {
+            name: 'wpackagist',
+            type: 'composer',
+            url: 'https://wpackagist.org',
+          },
+          { name: 'someGit', type: 'vcs', url: 'https://some-vcs.com' },
+          { name: 'somePath', type: 'path', url: '/some/path' },
+        ])
+      ).toEqual({
+        pathRepos: {
+          somePath: { name: 'somePath', type: 'path', url: '/some/path' },
+        },
+        registryUrls: ['https://wpackagist.org', 'https://packagist.org'],
+        gitRepos: {
+          someGit: {
+            name: 'someGit',
+            type: 'git',
+            url: 'https://some-vcs.com',
+          },
+        },
+      });
+    });
+
+    it(`parses repositories with packagist disabled`, () => {
+      expect(
+        Repos.parse({
+          wpackagist: { type: 'composer', url: 'https://wpackagist.org' },
+          someGit: { type: 'vcs', url: 'https://some-vcs.com' },
+          somePath: { type: 'path', url: '/some/path' },
+          packagist: false,
+        })
+      ).toEqual({
+        pathRepos: {
+          somePath: { name: 'somePath', type: 'path', url: '/some/path' },
+        },
+        registryUrls: ['https://wpackagist.org'],
+        gitRepos: {
+          someGit: {
+            name: 'someGit',
+            type: 'git',
+            url: 'https://some-vcs.com',
+          },
+        },
+      });
+    });
+  });
+});
diff --git a/lib/modules/manager/composer/schema.ts b/lib/modules/manager/composer/schema.ts
new file mode 100644
index 0000000000000000000000000000000000000000..fb8c2a8af2c9d7656ebc7079b2c0b1c1f43d9e64
--- /dev/null
+++ b/lib/modules/manager/composer/schema.ts
@@ -0,0 +1,318 @@
+import { z } from 'zod';
+import { logger } from '../../../logger';
+import { readLocalFile } from '../../../util/fs';
+import { regEx } from '../../../util/regex';
+import { Json, LooseArray, LooseRecord } from '../../../util/schema-utils';
+import { GitTagsDatasource } from '../../datasource/git-tags';
+import { GithubTagsDatasource } from '../../datasource/github-tags';
+import { PackagistDatasource } from '../../datasource/packagist';
+import { api as semverComposer } from '../../versioning/composer';
+import type { PackageDependency, PackageFileContent } from '../types';
+import type { ComposerManagerData } from './types';
+
+export const ComposerRepo = z.object({
+  type: z.literal('composer'),
+  /**
+   * The regUrl is expected to be a base URL. GitLab composer repository installation guide specifies
+   * to use a base URL containing packages.json. Composer still works in this scenario by determining
+   * whether to add / remove packages.json from the URL.
+   *
+   * See https://github.com/composer/composer/blob/750a92b4b7aecda0e5b2f9b963f1cb1421900675/src/Composer/Repository/ComposerRepository.php#L815
+   */
+  url: z.string().transform((url) => url.replace(/\/packages\.json$/, '')),
+});
+export type ComposerRepo = z.infer<typeof ComposerRepo>;
+
+export const GitRepo = z.object({
+  type: z.enum(['vcs', 'git']).transform(() => 'git' as const),
+  url: z.string(),
+});
+export type GitRepo = z.infer<typeof GitRepo>;
+
+export const PathRepo = z.object({
+  type: z.literal('path'),
+  url: z.string(),
+});
+export type PathRepo = z.infer<typeof PathRepo>;
+
+export const Repo = z.discriminatedUnion('type', [
+  ComposerRepo,
+  GitRepo,
+  PathRepo,
+]);
+export type Repo = z.infer<typeof ComposerRepo>;
+
+export const NamedRepo = z.discriminatedUnion('type', [
+  ComposerRepo,
+  GitRepo.extend({ name: z.string() }),
+  PathRepo.extend({ name: z.string() }),
+]);
+export type NamedRepo = z.infer<typeof NamedRepo>;
+
+const DisablePackagist = z.object({ type: z.literal('disable-packagist') });
+export type DisablePackagist = z.infer<typeof DisablePackagist>;
+
+export const ReposRecord = LooseRecord(z.union([Repo, z.literal(false)]), {
+  onError: ({ error: err }) => {
+    logger.warn({ err }, 'Composer: error parsing repositories object');
+  },
+}).transform((repos) => {
+  const result: (NamedRepo | DisablePackagist)[] = [];
+  for (const [name, repo] of Object.entries(repos)) {
+    if (repo === false) {
+      if (name === 'packagist' || name === 'packagist.org') {
+        result.push({ type: 'disable-packagist' });
+      }
+      continue;
+    }
+
+    if (repo.type === 'path' || repo.type === 'git') {
+      result.push({ name, ...repo });
+      continue;
+    }
+
+    if (repo.type === 'composer') {
+      result.push(repo);
+      continue;
+    }
+  }
+
+  return result;
+});
+export type ReposRecord = z.infer<typeof ReposRecord>;
+
+export const ReposArray = LooseArray(
+  z.union([
+    NamedRepo,
+    z
+      .union([
+        z.object({ packagist: z.literal(false) }),
+        z.object({ 'packagist.org': z.literal(false) }),
+      ])
+      .transform((): DisablePackagist => ({ type: 'disable-packagist' })),
+  ]),
+  {
+    onError: ({ error: err }) => {
+      logger.warn({ err }, 'Composer: error parsing repositories array');
+    },
+  }
+).transform((repos) => repos.filter((x): x is NamedRepo => x !== null));
+export type ReposArray = z.infer<typeof ReposArray>;
+
+export const Repos = z
+  .union([ReposRecord, ReposArray])
+  .default([]) // Prevents warnings for packages without repositories field
+  .catch(({ error: err }) => {
+    logger.warn({ err }, 'Composer: repositories parsing error');
+    return [];
+  })
+  .transform((repos) => {
+    let packagist = true;
+    const repoUrls: string[] = [];
+    const gitRepos: Record<string, GitRepo> = {};
+    const pathRepos: Record<string, PathRepo> = {};
+
+    for (const repo of repos) {
+      if (repo.type === 'composer') {
+        repoUrls.push(repo.url);
+      } else if (repo.type === 'git') {
+        gitRepos[repo.name] = repo;
+      } else if (repo.type === 'path') {
+        pathRepos[repo.name] = repo;
+      } else if (repo.type === 'disable-packagist') {
+        packagist = false;
+      }
+    }
+
+    if (packagist && repoUrls.length) {
+      repoUrls.push('https://packagist.org');
+    }
+    const registryUrls = repoUrls.length ? repoUrls : null;
+
+    return { registryUrls, gitRepos, pathRepos };
+  });
+export type Repos = z.infer<typeof Repos>;
+
+const RequireDefs = LooseRecord(z.string().transform((x) => x.trim())).catch(
+  {}
+);
+
+export const PackageFile = z
+  .object({
+    type: z.string().optional(),
+    config: z
+      .object({
+        platform: z.object({
+          php: z.string(),
+        }),
+      })
+      .nullable()
+      .catch(null),
+    repositories: Repos,
+    require: RequireDefs,
+    'require-dev': RequireDefs,
+  })
+  .transform(
+    ({
+      type: composerJsonType,
+      config,
+      repositories,
+      require,
+      'require-dev': requireDev,
+    }) => ({
+      composerJsonType,
+      config,
+      repositories,
+      require,
+      requireDev,
+    })
+  );
+export type PackageFile = z.infer<typeof PackageFile>;
+
+const LockedPackage = z.object({
+  name: z.string(),
+  version: z.string(),
+});
+type LockedPackage = z.infer<typeof LockedPackage>;
+
+export const Lockfile = z
+  .object({
+    'plugin-api-version': z.string().optional(),
+    packages: LooseArray(LockedPackage).catch([]),
+    'packages-dev': LooseArray(LockedPackage).catch([]),
+  })
+  .transform(
+    ({
+      'plugin-api-version': pluginApiVersion,
+      packages,
+      'packages-dev': packagesDev,
+    }) => ({ pluginApiVersion, packages, packagesDev })
+  );
+export type Lockfile = z.infer<typeof Lockfile>;
+
+export const ComposerExtract = z
+  .object({
+    content: z.string(),
+    fileName: z.string(),
+  })
+  .transform(({ content, fileName }) => {
+    const lockfileName = fileName.replace(/\.json$/, '.lock');
+    return {
+      file: content,
+      lockfileName,
+      lockfile: lockfileName,
+    };
+  })
+  .pipe(
+    z.object({
+      file: Json.pipe(PackageFile),
+      lockfileName: z.string(),
+      lockfile: z
+        .string()
+        .transform((lockfileName) => readLocalFile(lockfileName, 'utf8'))
+        .pipe(Json)
+        .pipe(Lockfile)
+        .nullable()
+        .catch(({ error: err }) => {
+          logger.warn({ err }, 'Composer: lockfile parsing error');
+          return null;
+        }),
+    })
+  )
+  .transform(({ file, lockfile, lockfileName }) => {
+    const { composerJsonType, require, requireDev } = file;
+    const { registryUrls, gitRepos, pathRepos } = file.repositories;
+
+    const deps: PackageDependency[] = [];
+
+    const profiles = [
+      {
+        depType: 'require',
+        req: require,
+        locked: lockfile?.packages ?? [],
+      },
+      {
+        depType: 'require-dev',
+        req: requireDev,
+        locked: lockfile?.packagesDev ?? [],
+      },
+    ];
+
+    for (const { depType, req, locked } of profiles) {
+      for (const [depName, currentValue] of Object.entries(req)) {
+        if (depName === 'php') {
+          deps.push({
+            depType,
+            depName,
+            currentValue,
+            datasource: GithubTagsDatasource.id,
+            packageName: 'php/php-src',
+            extractVersion: '^php-(?<version>.*)$',
+          });
+          continue;
+        }
+
+        if (pathRepos[depName]) {
+          deps.push({
+            depType,
+            depName,
+            currentValue,
+            skipReason: 'path-dependency',
+          });
+          continue;
+        }
+
+        const dep: PackageDependency = {
+          depType,
+          depName,
+          currentValue,
+        };
+
+        if (!depName.includes('/')) {
+          dep.skipReason = 'unsupported';
+        }
+
+        const lockedDep = locked.find((item) => item.name === depName);
+        if (lockedDep && semverComposer.isVersion(lockedDep.version)) {
+          dep.lockedVersion = lockedDep.version.replace(regEx(/^v/i), '');
+        }
+
+        const gitRepo = gitRepos[depName];
+        if (gitRepo) {
+          dep.datasource = GitTagsDatasource.id;
+          dep.packageName = gitRepo.url;
+          deps.push(dep);
+          continue;
+        }
+
+        dep.datasource = PackagistDatasource.id;
+
+        if (registryUrls) {
+          dep.registryUrls = registryUrls;
+        }
+
+        deps.push(dep);
+      }
+    }
+
+    if (!deps.length) {
+      return null;
+    }
+
+    const res: PackageFileContent<ComposerManagerData> = { deps };
+
+    if (composerJsonType) {
+      res.managerData = { composerJsonType };
+    }
+
+    if (require.php) {
+      res.extractedConstraints = { php: require.php };
+    }
+
+    if (lockfile) {
+      res.lockFiles = [lockfileName];
+    }
+
+    return res;
+  });
+export type ComposerExtract = z.infer<typeof ComposerExtract>;
diff --git a/lib/modules/manager/composer/types.ts b/lib/modules/manager/composer/types.ts
index c46f294d0cf861be30aa6e0156b01d7cc09f13cf..491dc7eac3f78c1031dddc64bb74a0d27fa50a83 100644
--- a/lib/modules/manager/composer/types.ts
+++ b/lib/modules/manager/composer/types.ts
@@ -1,49 +1,3 @@
-// istanbul ignore file: types only
-export interface Repo {
-  name?: string;
-  type: 'composer' | 'git' | 'package' | 'path' | 'vcs';
-  packagist?: boolean;
-  'packagist.org'?: boolean;
-  url: string;
-}
-export type ComposerRepositories = Record<string, Repo | boolean> | Repo[];
-
-export interface ComposerConfig {
-  type?: string;
-  /**
-   * Setting a fixed PHP version (e.g. {"php": "7.0.3"}) will let you fake the
-   * platform version so that you can emulate a production env or define your
-   * target platform in the config.
-   * See https://getcomposer.org/doc/06-config.md#platform
-   */
-  config?: {
-    platform?: {
-      php?: string;
-    };
-  };
-  /**
-   * A repositories field can be an array of Repo objects or an object of repoName: Repo
-   * Also it can be a boolean (usually false) to disable packagist.
-   * (Yes this can be confusing, as it is also not properly documented in the composer docs)
-   * See https://getcomposer.org/doc/05-repositories.md#disabling-packagist-org
-   */
-  repositories?: ComposerRepositories;
-
-  require?: Record<string, string>;
-  'require-dev'?: Record<string, string>;
-}
-
-export interface ComposerLockPackage {
-  name: string;
-  version: string;
-}
-
-export interface ComposerLock {
-  'plugin-api-version'?: string;
-  packages?: ComposerLockPackage[];
-  'packages-dev'?: ComposerLockPackage[];
-}
-
 export interface ComposerManagerData {
   composerJsonType?: string;
 }
diff --git a/lib/modules/manager/composer/update-locked.ts b/lib/modules/manager/composer/update-locked.ts
index fcfaa89d2f11c9de4d93aacb80a5ed63c3305b61..469ac1a81df64b811c00a20873814b73b05c6436 100644
--- a/lib/modules/manager/composer/update-locked.ts
+++ b/lib/modules/manager/composer/update-locked.ts
@@ -1,7 +1,8 @@
 import { logger } from '../../../logger';
+import { Json } from '../../../util/schema-utils';
 import { api as composer } from '../../versioning/composer';
 import type { UpdateLockedConfig, UpdateLockedResult } from '../types';
-import type { ComposerLock } from './types';
+import { Lockfile } from './schema';
 
 export function updateLockedDependency(
   config: UpdateLockedConfig
@@ -12,12 +13,11 @@ export function updateLockedDependency(
     `composer.updateLockedDependency: ${depName}@${currentVersion} -> ${newVersion} [${lockFile}]`
   );
   try {
-    const locked = JSON.parse(lockFileContent!) as ComposerLock;
+    const lockfile = Json.pipe(Lockfile).parse(lockFileContent);
     if (
-      locked.packages?.find(
-        (entry) =>
-          entry.name === depName &&
-          composer.equals(entry.version || '', newVersion)
+      lockfile?.packages.find(
+        ({ name, version }) =>
+          name === depName && composer.equals(version, newVersion)
       )
     ) {
       return { status: 'already-updated' };
diff --git a/lib/modules/manager/composer/utils.spec.ts b/lib/modules/manager/composer/utils.spec.ts
index 481df517e6f2602755d48033439d9c9297d9c975..c9e6117fccfd5b29cdb2ab64746ddec9b5e05e16 100644
--- a/lib/modules/manager/composer/utils.spec.ts
+++ b/lib/modules/manager/composer/utils.spec.ts
@@ -1,6 +1,7 @@
 import { GlobalConfig } from '../../../config/global';
 import * as hostRules from '../../../util/host-rules';
 import { GitTagsDatasource } from '../../datasource/git-tags';
+import { Lockfile, PackageFile } from './schema';
 import {
   extractConstraints,
   findGithubToken,
@@ -21,114 +22,121 @@ describe('modules/manager/composer/utils', () => {
 
   describe('extractConstraints', () => {
     it('returns from require', () => {
-      expect(
-        extractConstraints(
-          { require: { php: '>=5.3.2', 'composer/composer': '1.1.0' } },
-          {}
-        )
-      ).toEqual({ php: '>=5.3.2', composer: '1.1.0' });
+      const file = PackageFile.parse({
+        require: { php: '>=5.3.2', 'composer/composer': '1.1.0' },
+      });
+      const lockfile = Lockfile.parse({});
+      expect(extractConstraints(file, lockfile)).toEqual({
+        php: '>=5.3.2',
+        composer: '1.1.0',
+      });
     });
 
     it('returns platform php version', () => {
-      expect(
-        extractConstraints(
-          {
-            config: { platform: { php: '7.4.27' } },
-            require: { php: '~7.4 || ~8.0' },
-          },
-          {}
-        )
-      ).toEqual({ composer: '1.*', php: '<=7.4.27' });
+      const file = PackageFile.parse({
+        config: { platform: { php: '7.4.27' } },
+        require: { php: '~7.4 || ~8.0' },
+      });
+      const lockfile = Lockfile.parse({});
+      expect(extractConstraints(file, lockfile)).toEqual({
+        composer: '1.*',
+        php: '<=7.4.27',
+      });
     });
 
     it('returns platform 0 minor php version', () => {
-      expect(
-        extractConstraints(
-          {
-            config: { platform: { php: '7.0.5' } },
-            require: { php: '^7.0 || ~8.0' },
-          },
-          {}
-        )
-      ).toEqual({ composer: '1.*', php: '<=7.0.5' });
+      const file = PackageFile.parse({
+        config: { platform: { php: '7.0.5' } },
+        require: { php: '^7.0 || ~8.0' },
+      });
+      const lockfile = Lockfile.parse({});
+      expect(extractConstraints(file, lockfile)).toEqual({
+        composer: '1.*',
+        php: '<=7.0.5',
+      });
     });
 
     it('returns platform 0 patch php version', () => {
-      expect(
-        extractConstraints(
-          {
-            config: { platform: { php: '7.4.0' } },
-            require: { php: '^7.0 || ~8.0' },
-          },
-          {}
-        )
-      ).toEqual({ composer: '1.*', php: '<=7.4.0' });
+      const file = PackageFile.parse({
+        config: { platform: { php: '7.4.0' } },
+        require: { php: '^7.0 || ~8.0' },
+      });
+      const lockfile = Lockfile.parse({});
+      expect(extractConstraints(file, lockfile)).toEqual({
+        composer: '1.*',
+        php: '<=7.4.0',
+      });
     });
 
     it('returns platform lowest minor php version', () => {
-      expect(
-        extractConstraints(
-          {
-            config: { platform: { php: '7' } },
-            require: { php: '^7.0 || ~8.0' },
-          },
-          {}
-        )
-      ).toEqual({ composer: '1.*', php: '<=7.0.0' });
+      const file = PackageFile.parse({
+        config: { platform: { php: '7' } },
+        require: { php: '^7.0 || ~8.0' },
+      });
+      const lockfile = Lockfile.parse({});
+      expect(extractConstraints(file, lockfile)).toEqual({
+        composer: '1.*',
+        php: '<=7.0.0',
+      });
     });
 
     it('returns platform lowest patch php version', () => {
-      expect(
-        extractConstraints(
-          {
-            config: { platform: { php: '7.4' } },
-            require: { php: '~7.4 || ~8.0' },
-          },
-          {}
-        )
-      ).toEqual({ composer: '1.*', php: '<=7.4.0' });
+      const file = PackageFile.parse({
+        config: { platform: { php: '7.4' } },
+        require: { php: '~7.4 || ~8.0' },
+      });
+      const lockfile = Lockfile.parse({});
+      expect(extractConstraints(file, lockfile)).toEqual({
+        composer: '1.*',
+        php: '<=7.4.0',
+      });
     });
 
     it('returns from require-dev', () => {
-      expect(
-        extractConstraints(
-          { 'require-dev': { 'composer/composer': '1.1.0' } },
-          {}
-        )
-      ).toEqual({ composer: '1.1.0' });
+      const file = PackageFile.parse({
+        'require-dev': { 'composer/composer': '1.1.0' },
+      });
+      const lockfile = Lockfile.parse({});
+      expect(extractConstraints(file, lockfile)).toEqual({ composer: '1.1.0' });
     });
 
     it('returns from composer platform require', () => {
-      expect(
-        extractConstraints({ require: { php: '^8.1', composer: '2.2.0' } }, {})
-      ).toEqual({ php: '^8.1', composer: '2.2.0' });
+      const file = PackageFile.parse({
+        require: { php: '^8.1', composer: '2.2.0' },
+      });
+      const lockfile = Lockfile.parse({});
+      expect(extractConstraints(file, lockfile)).toEqual({
+        php: '^8.1',
+        composer: '2.2.0',
+      });
     });
 
     it('returns from composer platform require-dev', () => {
-      expect(
-        extractConstraints({ 'require-dev': { composer: '^2.2' } }, {})
-      ).toEqual({ composer: '^2.2' });
+      const file = PackageFile.parse({ 'require-dev': { composer: '^2.2' } });
+      const lockfile = Lockfile.parse({});
+      expect(extractConstraints(file, lockfile)).toEqual({ composer: '^2.2' });
     });
 
     it('returns from composer-runtime-api', () => {
-      expect(
-        extractConstraints(
-          { require: { 'composer-runtime-api': '^1.1.0' } },
-          {}
-        )
-      ).toEqual({ composer: '^1.1' });
+      const file = PackageFile.parse({
+        require: { 'composer-runtime-api': '^1.1.0' },
+      });
+      const lockfile = Lockfile.parse({});
+      expect(extractConstraints(file, lockfile)).toEqual({ composer: '^1.1' });
     });
 
     it('returns from plugin-api-version', () => {
-      expect(extractConstraints({}, { 'plugin-api-version': '1.1.0' })).toEqual(
-        {
-          composer: '^1.1',
-        }
-      );
+      const file = PackageFile.parse({});
+      const lockfile = Lockfile.parse({ 'plugin-api-version': '1.1.0' });
+      expect(extractConstraints(file, lockfile)).toEqual({
+        composer: '^1.1',
+      });
     });
 
     it('fallback to 1.*', () => {
-      expect(extractConstraints({}, {})).toEqual({ composer: '1.*' });
+      const file = PackageFile.parse({});
+      const lockfile = Lockfile.parse({});
+      expect(extractConstraints(file, lockfile)).toEqual({ composer: '1.*' });
     });
   });
 
@@ -276,27 +284,24 @@ describe('modules/manager/composer/utils', () => {
 
   describe('requireComposerDependencyInstallation', () => {
     it('returns true when symfony/flex has been installed', () => {
-      expect(
-        requireComposerDependencyInstallation({
-          packages: [{ name: 'symfony/flex', version: '1.17.1' }],
-        })
-      ).toBeTrue();
+      const lockfile = Lockfile.parse({
+        packages: [{ name: 'symfony/flex', version: '1.17.1' }],
+      });
+      expect(requireComposerDependencyInstallation(lockfile)).toBeTrue();
     });
 
     it('returns true when symfony/flex has been installed as dev dependency', () => {
-      expect(
-        requireComposerDependencyInstallation({
-          'packages-dev': [{ name: 'symfony/flex', version: '1.17.1' }],
-        })
-      ).toBeTrue();
+      const lockfile = Lockfile.parse({
+        'packages-dev': [{ name: 'symfony/flex', version: '1.17.1' }],
+      });
+      expect(requireComposerDependencyInstallation(lockfile)).toBeTrue();
     });
 
     it('returns false when symfony/flex has not been installed', () => {
-      expect(
-        requireComposerDependencyInstallation({
-          packages: [{ name: 'symfony/console', version: '5.4.0' }],
-        })
-      ).toBeFalse();
+      const lockfile = Lockfile.parse({
+        packages: [{ name: 'symfony/console', version: '5.4.0' }],
+      });
+      expect(requireComposerDependencyInstallation(lockfile)).toBeFalse();
     });
   });
 
diff --git a/lib/modules/manager/composer/utils.ts b/lib/modules/manager/composer/utils.ts
index 8342798971adb7cdec3f20ccbba900da86a09a6a..5a73be6c9120acd8ac78712b621a0288168dbb1f 100644
--- a/lib/modules/manager/composer/utils.ts
+++ b/lib/modules/manager/composer/utils.ts
@@ -7,7 +7,7 @@ import type { HostRuleSearchResult } from '../../../types';
 import type { ToolConstraint } from '../../../util/exec/types';
 import { api, id as composerVersioningId } from '../../versioning/composer';
 import type { UpdateArtifactsConfig } from '../types';
-import type { ComposerConfig, ComposerLock } from './types';
+import type { Lockfile, PackageFile } from './schema';
 
 export { composerVersioningId };
 
@@ -59,53 +59,55 @@ export function getPhpConstraint(
   return null;
 }
 
-export function requireComposerDependencyInstallation(
-  lock: ComposerLock
-): boolean {
+export function requireComposerDependencyInstallation({
+  packages,
+  packagesDev,
+}: Lockfile): boolean {
   return (
-    lock.packages?.some((p) => depRequireInstall.has(p.name)) === true ||
-    lock['packages-dev']?.some((p) => depRequireInstall.has(p.name)) === true
+    packages.some((p) => depRequireInstall.has(p.name)) === true ||
+    packagesDev.some((p) => depRequireInstall.has(p.name)) === true
   );
 }
 
 export function extractConstraints(
-  composerJson: ComposerConfig,
-  lockParsed: ComposerLock
+  { config, require, requireDev }: PackageFile,
+  { pluginApiVersion }: Lockfile
 ): Record<string, string> {
   const res: Record<string, string> = { composer: '1.*' };
 
   // extract php
-  if (composerJson.config?.platform?.php) {
-    const major = api.getMajor(composerJson.config.platform.php);
-    const minor = api.getMinor(composerJson.config.platform.php) ?? 0;
-    const patch = api.getPatch(composerJson.config.platform.php) ?? 0;
+  const phpVersion = config?.platform.php;
+  if (phpVersion) {
+    const major = api.getMajor(phpVersion);
+    const minor = api.getMinor(phpVersion) ?? 0;
+    const patch = api.getPatch(phpVersion) ?? 0;
     res.php = `<=${major}.${minor}.${patch}`;
-  } else if (composerJson.require?.php) {
-    res.php = composerJson.require.php;
+  } else if (require.php) {
+    res.php = require.php;
   }
 
   // extract direct composer dependency
-  if (composerJson.require?.['composer/composer']) {
-    res.composer = composerJson.require?.['composer/composer'];
-  } else if (composerJson['require-dev']?.['composer/composer']) {
-    res.composer = composerJson['require-dev']?.['composer/composer'];
+  if (require['composer/composer']) {
+    res.composer = require['composer/composer'];
+  } else if (requireDev['composer/composer']) {
+    res.composer = requireDev['composer/composer'];
   }
   // composer platform package
-  else if (composerJson.require?.['composer']) {
-    res.composer = composerJson.require?.['composer'];
-  } else if (composerJson['require-dev']?.['composer']) {
-    res.composer = composerJson['require-dev']?.['composer'];
+  else if (require['composer']) {
+    res.composer = require['composer'];
+  } else if (requireDev['composer']) {
+    res.composer = requireDev['composer'];
   }
   // check last used composer version
-  else if (lockParsed?.['plugin-api-version']) {
-    const major = api.getMajor(lockParsed?.['plugin-api-version']);
-    const minor = api.getMinor(lockParsed?.['plugin-api-version']);
+  else if (pluginApiVersion) {
+    const major = api.getMajor(pluginApiVersion);
+    const minor = api.getMinor(pluginApiVersion);
     res.composer = `^${major}.${minor}`;
   }
   // check composer api dependency
-  else if (composerJson.require?.['composer-runtime-api']) {
-    const major = api.getMajor(composerJson.require?.['composer-runtime-api']);
-    const minor = api.getMinor(composerJson.require?.['composer-runtime-api']);
+  else if (require['composer-runtime-api']) {
+    const major = api.getMajor(require['composer-runtime-api']);
+    const minor = api.getMinor(require['composer-runtime-api']);
     res.composer = `^${major}.${minor}`;
   }
   return res;